Initial commit - Horux Despachos NL
This commit is contained in:
82
apps/api/.env.example
Normal file
82
apps/api/.env.example
Normal 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
65
apps/api/package.json
Normal file
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"name": "@horux/api",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"author": "Carlos e Ivan (Horux 360)",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"lint": "eslint src/",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"db:generate": "prisma generate",
|
||||
"db:push": "prisma db push",
|
||||
"db:migrate": "prisma migrate dev",
|
||||
"db:seed": "tsx prisma/seed.ts",
|
||||
"import:lista-negra": "tsx scripts/import-lista-negra.ts",
|
||||
"db:migrate-tenants": "tsx scripts/migrate-tenants.ts",
|
||||
"bootstrap:admin-global": "tsx scripts/bootstrap-horux360-admin.ts",
|
||||
"legal:sync": "node scripts/extract-terminos.mjs",
|
||||
"email:preview": "tsx scripts/preview-emails.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@horux/core": "workspace:*",
|
||||
"@horux/shared": "workspace:*",
|
||||
"@nodecfdi/cfdi-core": "^1.0.1",
|
||||
"@nodecfdi/credentials": "^3.2.0",
|
||||
"@nodecfdi/sat-ws-descarga-masiva": "^2.0.0",
|
||||
"@prisma/client": "^5.22.0",
|
||||
"adm-zip": "^0.5.16",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^17.2.3",
|
||||
"exceljs": "^4.4.0",
|
||||
"express": "^4.21.0",
|
||||
"facturapi": "^4.14.2",
|
||||
"fast-xml-parser": "^5.3.3",
|
||||
"helmet": "^8.0.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"mercadopago": "^2.12.0",
|
||||
"node-cron": "^4.2.1",
|
||||
"node-forge": "^1.3.3",
|
||||
"nodemailer": "^8.0.2",
|
||||
"pdf-parse": "^2.4.5",
|
||||
"pg": "^8.18.0",
|
||||
"playwright": "^1.59.1",
|
||||
"zod": "^3.23.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/adm-zip": "^0.5.7",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jsonwebtoken": "^9.0.7",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"@types/node-forge": "^1.3.14",
|
||||
"@types/nodemailer": "^7.0.11",
|
||||
"@types/pg": "^8.18.0",
|
||||
"express-rate-limit": "^8.3.1",
|
||||
"prisma": "^5.22.0",
|
||||
"sql.js": "^1.14.1",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.3.0"
|
||||
}
|
||||
}
|
||||
121
apps/api/prisma/catalogos-sat-data.ts
Normal file
121
apps/api/prisma/catalogos-sat-data.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
// Catálogos SAT CFDI 4.0 para facturación
|
||||
|
||||
export const FORMAS_PAGO = [
|
||||
{ clave: '01', descripcion: 'Efectivo' },
|
||||
{ clave: '02', descripcion: 'Cheque nominativo' },
|
||||
{ clave: '03', descripcion: 'Transferencia electrónica de fondos' },
|
||||
{ clave: '04', descripcion: 'Tarjeta de crédito' },
|
||||
{ clave: '05', descripcion: 'Monedero electrónico' },
|
||||
{ clave: '06', descripcion: 'Dinero electrónico' },
|
||||
{ clave: '08', descripcion: 'Vales de despensa' },
|
||||
{ clave: '12', descripcion: 'Dación en pago' },
|
||||
{ clave: '13', descripcion: 'Pago por subrogación' },
|
||||
{ clave: '14', descripcion: 'Pago por consignación' },
|
||||
{ clave: '15', descripcion: 'Condonación' },
|
||||
{ clave: '17', descripcion: 'Compensación' },
|
||||
{ clave: '23', descripcion: 'Novación' },
|
||||
{ clave: '24', descripcion: 'Confusión' },
|
||||
{ clave: '25', descripcion: 'Remisión de deuda' },
|
||||
{ clave: '26', descripcion: 'Prescripción o caducidad' },
|
||||
{ clave: '27', descripcion: 'A satisfacción del acreedor' },
|
||||
{ clave: '28', descripcion: 'Tarjeta de débito' },
|
||||
{ clave: '29', descripcion: 'Tarjeta de servicios' },
|
||||
{ clave: '30', descripcion: 'Aplicación de anticipos' },
|
||||
{ clave: '31', descripcion: 'Intermediario pagos' },
|
||||
{ clave: '99', descripcion: 'Por definir' },
|
||||
];
|
||||
|
||||
export const METODOS_PAGO = [
|
||||
{ clave: 'PUE', descripcion: 'Pago en una sola exhibición' },
|
||||
{ clave: 'PPD', descripcion: 'Pago en parcialidades o diferido' },
|
||||
];
|
||||
|
||||
export const USOS_CFDI = [
|
||||
{ clave: 'G01', descripcion: 'Adquisición de mercancías', personaFisica: true, personaMoral: true },
|
||||
{ clave: 'G02', descripcion: 'Devoluciones, descuentos o bonificaciones', personaFisica: true, personaMoral: true },
|
||||
{ clave: 'G03', descripcion: 'Gastos en general', personaFisica: true, personaMoral: true },
|
||||
{ clave: 'I01', descripcion: 'Construcciones', personaFisica: true, personaMoral: true },
|
||||
{ clave: 'I02', descripcion: 'Mobiliario y equipo de oficina por inversiones', personaFisica: true, personaMoral: true },
|
||||
{ clave: 'I03', descripcion: 'Equipo de transporte', personaFisica: true, personaMoral: true },
|
||||
{ clave: 'I04', descripcion: 'Equipo de cómputo y accesorios', personaFisica: true, personaMoral: true },
|
||||
{ clave: 'I05', descripcion: 'Dados, troqueles, moldes, matrices y herramental', personaFisica: true, personaMoral: true },
|
||||
{ clave: 'I06', descripcion: 'Comunicaciones telefónicas', personaFisica: true, personaMoral: true },
|
||||
{ clave: 'I07', descripcion: 'Comunicaciones satelitales', personaFisica: true, personaMoral: true },
|
||||
{ clave: 'I08', descripcion: 'Otra maquinaria y equipo', personaFisica: true, personaMoral: true },
|
||||
{ clave: 'D01', descripcion: 'Honorarios médicos, dentales y gastos hospitalarios', personaFisica: true, personaMoral: false },
|
||||
{ clave: 'D02', descripcion: 'Gastos médicos por incapacidad o discapacidad', personaFisica: true, personaMoral: false },
|
||||
{ clave: 'D03', descripcion: 'Gastos funerales', personaFisica: true, personaMoral: false },
|
||||
{ clave: 'D04', descripcion: 'Donativos', personaFisica: true, personaMoral: true },
|
||||
{ clave: 'D05', descripcion: 'Intereses reales efectivamente pagados por créditos hipotecarios', personaFisica: true, personaMoral: false },
|
||||
{ clave: 'D06', descripcion: 'Aportaciones voluntarias al SAR', personaFisica: true, personaMoral: false },
|
||||
{ clave: 'D07', descripcion: 'Primas por seguros de gastos médicos', personaFisica: true, personaMoral: false },
|
||||
{ clave: 'D08', descripcion: 'Gastos de transportación escolar obligatoria', personaFisica: true, personaMoral: false },
|
||||
{ clave: 'D09', descripcion: 'Depósitos en cuentas para el ahorro, primas de pensiones', personaFisica: true, personaMoral: false },
|
||||
{ clave: 'D10', descripcion: 'Pagos por servicios educativos (colegiaturas)', personaFisica: true, personaMoral: false },
|
||||
{ clave: 'S01', descripcion: 'Sin efectos fiscales', personaFisica: true, personaMoral: true },
|
||||
{ clave: 'CP01', descripcion: 'Pagos', personaFisica: true, personaMoral: true },
|
||||
{ clave: 'CN01', descripcion: 'Nómina', personaFisica: true, personaMoral: false },
|
||||
];
|
||||
|
||||
export const MONEDAS = [
|
||||
{ clave: 'MXN', descripcion: 'Peso Mexicano', decimales: 2 },
|
||||
{ clave: 'USD', descripcion: 'Dólar Americano', decimales: 2 },
|
||||
{ clave: 'EUR', descripcion: 'Euro', decimales: 2 },
|
||||
{ clave: 'GBP', descripcion: 'Libra Esterlina', decimales: 2 },
|
||||
{ clave: 'CAD', descripcion: 'Dólar Canadiense', decimales: 2 },
|
||||
{ clave: 'JPY', descripcion: 'Yen Japonés', decimales: 0 },
|
||||
{ clave: 'XXX', descripcion: 'Los códigos asignados para transacciones en que intervenga ninguna moneda', decimales: 0 },
|
||||
];
|
||||
|
||||
export const CLAVES_UNIDAD = [
|
||||
{ clave: 'H87', descripcion: 'Pieza' },
|
||||
{ clave: 'E48', descripcion: 'Unidad de servicio' },
|
||||
{ clave: 'KGM', descripcion: 'Kilogramo' },
|
||||
{ clave: 'LTR', descripcion: 'Litro' },
|
||||
{ clave: 'MTR', descripcion: 'Metro' },
|
||||
{ clave: 'MTK', descripcion: 'Metro cuadrado' },
|
||||
{ clave: 'MTQ', descripcion: 'Metro cúbico' },
|
||||
{ clave: 'KWH', descripcion: 'Kilovatio hora' },
|
||||
{ clave: 'TNE', descripcion: 'Tonelada' },
|
||||
{ clave: 'GRM', descripcion: 'Gramo' },
|
||||
{ clave: 'HUR', descripcion: 'Hora' },
|
||||
{ clave: 'DAY', descripcion: 'Día' },
|
||||
{ clave: 'MON', descripcion: 'Mes' },
|
||||
{ clave: 'ANN', descripcion: 'Año' },
|
||||
{ clave: 'XBX', descripcion: 'Caja' },
|
||||
{ clave: 'XPK', descripcion: 'Paquete' },
|
||||
{ clave: 'XKI', descripcion: 'Kit' },
|
||||
{ clave: 'SET', descripcion: 'Conjunto' },
|
||||
{ clave: 'XLT', descripcion: 'Lote' },
|
||||
{ clave: 'ACT', descripcion: 'Actividad' },
|
||||
{ clave: 'XUN', descripcion: 'Unidad' },
|
||||
{ clave: 'DPC', descripcion: 'Docena de piezas' },
|
||||
{ clave: 'XRO', descripcion: 'Rollo' },
|
||||
{ clave: 'GLL', descripcion: 'Galón' },
|
||||
{ clave: 'MLT', descripcion: 'Mililitro' },
|
||||
{ clave: 'CMT', descripcion: 'Centímetro' },
|
||||
];
|
||||
|
||||
export const OBJETOS_IMP = [
|
||||
{ clave: '01', descripcion: 'No objeto de impuesto' },
|
||||
{ clave: '02', descripcion: 'Sí objeto de impuesto' },
|
||||
{ clave: '03', descripcion: 'Sí objeto del impuesto y no obligado al desglose' },
|
||||
{ clave: '04', descripcion: 'Sí objeto del impuesto y no causa impuesto' },
|
||||
];
|
||||
|
||||
export const TIPOS_RELACION = [
|
||||
{ clave: '01', descripcion: 'Nota de crédito de los documentos relacionados' },
|
||||
{ clave: '02', descripcion: 'Nota de débito de los documentos relacionados' },
|
||||
{ clave: '03', descripcion: 'Devolución de mercancía sobre facturas o traslados previos' },
|
||||
{ clave: '04', descripcion: 'Sustitución de los CFDI previos' },
|
||||
{ clave: '05', descripcion: 'Traslados de mercancías facturados previamente' },
|
||||
{ clave: '06', descripcion: 'Factura generada por los traslados previos' },
|
||||
{ clave: '07', descripcion: 'CFDI por aplicación de anticipo' },
|
||||
];
|
||||
|
||||
export const EXPORTACIONES = [
|
||||
{ clave: '01', descripcion: 'No aplica' },
|
||||
{ clave: '02', descripcion: 'Definitiva' },
|
||||
{ clave: '03', descripcion: 'Temporal' },
|
||||
{ clave: '04', descripcion: 'Definitiva con clave distinta a A1 o cuando no existe enajenación en términos del CFF' },
|
||||
];
|
||||
185
apps/api/prisma/eventos-fiscales-data.ts
Normal file
185
apps/api/prisma/eventos-fiscales-data.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
// Catálogo de eventos fiscales
|
||||
export const EVENTOS_FISCALES = [
|
||||
{
|
||||
titulo: 'Declaración mensual ISR',
|
||||
tipo: 'declaracion',
|
||||
diaBase: 17,
|
||||
mesRelativo: 1,
|
||||
recurrencia: 'mensual',
|
||||
usaExtensionRfc: false,
|
||||
regimenes: 'todos',
|
||||
condicion: null,
|
||||
},
|
||||
{
|
||||
titulo: 'Declaración mensual IVA',
|
||||
tipo: 'declaracion',
|
||||
diaBase: 17,
|
||||
mesRelativo: 1,
|
||||
recurrencia: 'mensual',
|
||||
usaExtensionRfc: false,
|
||||
regimenes: 'todos',
|
||||
condicion: null,
|
||||
},
|
||||
{
|
||||
titulo: 'Declaración mensual IEPS',
|
||||
tipo: 'declaracion',
|
||||
diaBase: 17,
|
||||
mesRelativo: 1,
|
||||
recurrencia: 'mensual',
|
||||
usaExtensionRfc: false,
|
||||
regimenes: 'todos',
|
||||
condicion: null,
|
||||
},
|
||||
{
|
||||
titulo: 'Declaración de sueldos y salarios',
|
||||
tipo: 'declaracion',
|
||||
diaBase: 17,
|
||||
mesRelativo: 1,
|
||||
recurrencia: 'mensual',
|
||||
usaExtensionRfc: false,
|
||||
regimenes: 'todos',
|
||||
condicion: 'tiene_nomina',
|
||||
},
|
||||
{
|
||||
titulo: 'Pago provisional ISR',
|
||||
tipo: 'pago',
|
||||
diaBase: 17,
|
||||
mesRelativo: 1,
|
||||
recurrencia: 'mensual',
|
||||
usaExtensionRfc: true,
|
||||
regimenes: 'todos',
|
||||
condicion: null,
|
||||
},
|
||||
{
|
||||
titulo: 'Pago provisional IVA',
|
||||
tipo: 'pago',
|
||||
diaBase: 17,
|
||||
mesRelativo: 1,
|
||||
recurrencia: 'mensual',
|
||||
usaExtensionRfc: true,
|
||||
regimenes: 'todos',
|
||||
condicion: null,
|
||||
},
|
||||
{
|
||||
titulo: 'Pago provisional IEPS',
|
||||
tipo: 'pago',
|
||||
diaBase: 17,
|
||||
mesRelativo: 1,
|
||||
recurrencia: 'mensual',
|
||||
usaExtensionRfc: true,
|
||||
regimenes: 'todos',
|
||||
condicion: null,
|
||||
},
|
||||
{
|
||||
titulo: 'DIOT',
|
||||
tipo: 'obligacion',
|
||||
diaBase: 17,
|
||||
mesRelativo: 1,
|
||||
recurrencia: 'mensual',
|
||||
usaExtensionRfc: false,
|
||||
regimenes: '601,603,607,608,610,611,612,614,615,620,622,623,624',
|
||||
condicion: null, // 612 aplica condición ingresos_4m, se valida en runtime
|
||||
},
|
||||
{
|
||||
titulo: 'Contabilidad electrónica',
|
||||
tipo: 'obligacion',
|
||||
diaBase: 3,
|
||||
mesRelativo: 2,
|
||||
recurrencia: 'mensual',
|
||||
usaExtensionRfc: false,
|
||||
regimenes: '601,603,607,608,610,611,612,614,615,620,622,623,624',
|
||||
condicion: null,
|
||||
},
|
||||
{
|
||||
titulo: 'Declaración anual PM',
|
||||
tipo: 'declaracion',
|
||||
diaBase: 31,
|
||||
mesRelativo: 0,
|
||||
mesFijo: 3,
|
||||
recurrencia: 'anual',
|
||||
usaExtensionRfc: false,
|
||||
regimenes: '601,603,620,622,623,624',
|
||||
condicion: null,
|
||||
},
|
||||
{
|
||||
titulo: 'Declaración anual PF',
|
||||
tipo: 'declaracion',
|
||||
diaBase: 30,
|
||||
mesRelativo: 0,
|
||||
mesFijo: 4,
|
||||
recurrencia: 'anual',
|
||||
usaExtensionRfc: false,
|
||||
regimenes: '605,606,607,608,611,612,614,615,621,625,626',
|
||||
condicion: null,
|
||||
},
|
||||
{
|
||||
titulo: 'Informativa Sueldos y Salarios',
|
||||
tipo: 'informativa',
|
||||
diaBase: 15,
|
||||
mesRelativo: 0,
|
||||
mesFijo: 2,
|
||||
recurrencia: 'anual',
|
||||
usaExtensionRfc: false,
|
||||
regimenes: 'todos',
|
||||
condicion: 'tiene_nomina',
|
||||
},
|
||||
];
|
||||
|
||||
// Días festivos oficiales de México (2020-2027)
|
||||
// Incluye: 1 ene, 5 feb, 21 mar, 1 may, 16 sep, 1 oct (cambio poder), 20 nov, 25 dic
|
||||
// + cambios de poder cada 6 años, semana santa variable
|
||||
export const DIAS_INHABILES: { fecha: string; nombre: string }[] = [];
|
||||
|
||||
function addFestivos(año: number) {
|
||||
const fijos = [
|
||||
{ mes: 1, dia: 1, nombre: 'Año Nuevo' },
|
||||
{ mes: 5, dia: 1, nombre: 'Día del Trabajo' },
|
||||
{ mes: 9, dia: 16, nombre: 'Independencia de México' },
|
||||
{ mes: 12, dia: 25, nombre: 'Navidad' },
|
||||
];
|
||||
|
||||
// Primer lunes de febrero (Constitución)
|
||||
const feb1 = new Date(año, 1, 1);
|
||||
const primerLunesFeb = new Date(año, 1, 1 + ((8 - feb1.getDay()) % 7));
|
||||
DIAS_INHABILES.push({
|
||||
fecha: primerLunesFeb.toISOString().split('T')[0],
|
||||
nombre: 'Día de la Constitución',
|
||||
});
|
||||
|
||||
// Tercer lunes de marzo (Benito Juárez)
|
||||
const mar1 = new Date(año, 2, 1);
|
||||
const primerLunesMar = new Date(año, 2, 1 + ((8 - mar1.getDay()) % 7));
|
||||
const tercerLunesMar = new Date(primerLunesMar);
|
||||
tercerLunesMar.setDate(tercerLunesMar.getDate() + 14);
|
||||
DIAS_INHABILES.push({
|
||||
fecha: tercerLunesMar.toISOString().split('T')[0],
|
||||
nombre: 'Natalicio de Benito Juárez',
|
||||
});
|
||||
|
||||
// Tercer lunes de noviembre (Revolución)
|
||||
const nov1 = new Date(año, 10, 1);
|
||||
const primerLunesNov = new Date(año, 10, 1 + ((8 - nov1.getDay()) % 7));
|
||||
const tercerLunesNov = new Date(primerLunesNov);
|
||||
tercerLunesNov.setDate(tercerLunesNov.getDate() + 14);
|
||||
DIAS_INHABILES.push({
|
||||
fecha: tercerLunesNov.toISOString().split('T')[0],
|
||||
nombre: 'Día de la Revolución',
|
||||
});
|
||||
|
||||
for (const f of fijos) {
|
||||
DIAS_INHABILES.push({
|
||||
fecha: `${año}-${String(f.mes).padStart(2, '0')}-${String(f.dia).padStart(2, '0')}`,
|
||||
nombre: f.nombre,
|
||||
});
|
||||
}
|
||||
|
||||
// Cambio de poder (1 oct cada 6 años: 2024, 2030...)
|
||||
if (año % 6 === 0 || (año - 2024) % 6 === 0) {
|
||||
DIAS_INHABILES.push({
|
||||
fecha: `${año}-10-01`,
|
||||
nombre: 'Transmisión del Poder Ejecutivo Federal',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (let y = 2020; y <= 2027; y++) addFestivos(y);
|
||||
103
apps/api/prisma/isr-data.ts
Normal file
103
apps/api/prisma/isr-data.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
// Tasas RESICO (Art. 113-E) - iguales 2022-2026
|
||||
export const RESICO_TASAS = [
|
||||
{ montoMaximo: 25000.00, porcentaje: 1.00 },
|
||||
{ montoMaximo: 50000.00, porcentaje: 1.10 },
|
||||
{ montoMaximo: 83888.33, porcentaje: 1.50 },
|
||||
{ montoMaximo: 208333.33, porcentaje: 2.00 },
|
||||
{ montoMaximo: 291666.66, porcentaje: 2.50 },
|
||||
];
|
||||
|
||||
// Tarifas ISR mensuales (Art. 96) por año
|
||||
export const ISR_TARIFAS: Record<number, { li: number; ls: number | null; cf: number; pe: number }[]> = {
|
||||
2020: [
|
||||
{ li: 0.01, ls: 578.52, cf: 0, pe: 1.92 },
|
||||
{ li: 578.53, ls: 4910.18, cf: 11.11, pe: 6.40 },
|
||||
{ li: 4910.19, ls: 8629.20, cf: 288.33, pe: 10.88 },
|
||||
{ li: 8629.21, ls: 10031.07, cf: 692.96, pe: 16.00 },
|
||||
{ li: 10031.08, ls: 12009.94, cf: 917.26, pe: 17.92 },
|
||||
{ li: 12009.95, ls: 24222.31, cf: 1271.87, pe: 21.36 },
|
||||
{ li: 24222.32, ls: 38177.69, cf: 3880.44, pe: 23.52 },
|
||||
{ li: 38177.70, ls: 72887.50, cf: 7162.74, pe: 30.00 },
|
||||
{ li: 72887.51, ls: 97183.33, cf: 17575.69, pe: 32.00 },
|
||||
{ li: 97183.34, ls: 291550.00, cf: 25350.35, pe: 34.00 },
|
||||
{ li: 291550.01, ls: null, cf: 91435.02, pe: 35.00 },
|
||||
],
|
||||
2021: [
|
||||
{ li: 0.01, ls: 644.58, cf: 0, pe: 1.92 },
|
||||
{ li: 644.59, ls: 5470.92, cf: 12.38, pe: 6.40 },
|
||||
{ li: 5470.93, ls: 9614.66, cf: 321.26, pe: 10.88 },
|
||||
{ li: 9614.67, ls: 11176.62, cf: 772.10, pe: 16.00 },
|
||||
{ li: 11176.63, ls: 13381.47, cf: 1022.01, pe: 17.92 },
|
||||
{ li: 13381.48, ls: 26988.50, cf: 1417.12, pe: 21.36 },
|
||||
{ li: 26988.51, ls: 42537.58, cf: 4323.58, pe: 23.52 },
|
||||
{ li: 42537.59, ls: 81211.25, cf: 7980.73, pe: 30.00 },
|
||||
{ li: 81211.26, ls: 108281.67, cf: 19582.83, pe: 32.00 },
|
||||
{ li: 108281.68, ls: 324845.01, cf: 28245.36, pe: 34.00 },
|
||||
{ li: 324845.02, ls: null, cf: 101876.90, pe: 35.00 },
|
||||
],
|
||||
2022: [
|
||||
{ li: 0.01, ls: 644.58, cf: 0, pe: 1.92 },
|
||||
{ li: 644.59, ls: 5470.92, cf: 12.38, pe: 6.40 },
|
||||
{ li: 5470.93, ls: 9614.66, cf: 321.26, pe: 10.88 },
|
||||
{ li: 9614.67, ls: 11176.62, cf: 772.10, pe: 16.00 },
|
||||
{ li: 11176.63, ls: 13381.47, cf: 1022.01, pe: 17.92 },
|
||||
{ li: 13381.48, ls: 26988.50, cf: 1417.12, pe: 21.36 },
|
||||
{ li: 26988.51, ls: 42537.58, cf: 4323.58, pe: 23.52 },
|
||||
{ li: 42537.59, ls: 81211.25, cf: 7980.73, pe: 30.00 },
|
||||
{ li: 81211.26, ls: 108281.67, cf: 19582.83, pe: 32.00 },
|
||||
{ li: 108281.68, ls: 324845.01, cf: 28245.36, pe: 34.00 },
|
||||
{ li: 324845.02, ls: null, cf: 101876.90, pe: 35.00 },
|
||||
],
|
||||
2023: [
|
||||
{ li: 0.01, ls: 746.04, cf: 0, pe: 1.92 },
|
||||
{ li: 746.05, ls: 6332.05, cf: 14.32, pe: 6.40 },
|
||||
{ li: 6332.06, ls: 11128.01, cf: 371.83, pe: 10.88 },
|
||||
{ li: 11128.02, ls: 12935.82, cf: 893.63, pe: 16.00 },
|
||||
{ li: 12935.83, ls: 15487.71, cf: 1182.88, pe: 17.92 },
|
||||
{ li: 15487.72, ls: 31236.49, cf: 1640.18, pe: 21.36 },
|
||||
{ li: 31236.50, ls: 49233.00, cf: 5004.12, pe: 23.52 },
|
||||
{ li: 49233.01, ls: 93993.90, cf: 9236.89, pe: 30.00 },
|
||||
{ li: 93993.91, ls: 125325.20, cf: 22665.17, pe: 32.00 },
|
||||
{ li: 125325.21, ls: 375975.61, cf: 32691.18, pe: 34.00 },
|
||||
{ li: 375975.62, ls: null, cf: 117912.32, pe: 35.00 },
|
||||
],
|
||||
2024: [
|
||||
{ li: 0.01, ls: 746.04, cf: 0, pe: 1.92 },
|
||||
{ li: 746.05, ls: 6332.05, cf: 14.32, pe: 6.40 },
|
||||
{ li: 6332.06, ls: 11128.01, cf: 371.83, pe: 10.88 },
|
||||
{ li: 11128.02, ls: 12935.82, cf: 893.63, pe: 16.00 },
|
||||
{ li: 12935.83, ls: 15487.71, cf: 1182.88, pe: 17.92 },
|
||||
{ li: 15487.72, ls: 31236.49, cf: 1640.18, pe: 21.36 },
|
||||
{ li: 31236.50, ls: 49233.00, cf: 5004.12, pe: 23.52 },
|
||||
{ li: 49233.01, ls: 93993.90, cf: 9236.89, pe: 30.00 },
|
||||
{ li: 93993.91, ls: 125325.20, cf: 22665.17, pe: 32.00 },
|
||||
{ li: 125325.21, ls: 375975.61, cf: 32691.18, pe: 34.00 },
|
||||
{ li: 375975.62, ls: null, cf: 117912.32, pe: 35.00 },
|
||||
],
|
||||
2025: [
|
||||
{ li: 0.01, ls: 746.04, cf: 0, pe: 1.92 },
|
||||
{ li: 746.05, ls: 6332.05, cf: 14.32, pe: 6.40 },
|
||||
{ li: 6332.06, ls: 11128.01, cf: 371.83, pe: 10.88 },
|
||||
{ li: 11128.02, ls: 12935.82, cf: 893.63, pe: 16.00 },
|
||||
{ li: 12935.83, ls: 15487.71, cf: 1182.88, pe: 17.92 },
|
||||
{ li: 15487.72, ls: 31236.49, cf: 1640.18, pe: 21.36 },
|
||||
{ li: 31236.50, ls: 49233.00, cf: 5004.12, pe: 23.52 },
|
||||
{ li: 49233.01, ls: 93993.90, cf: 9236.89, pe: 30.00 },
|
||||
{ li: 93993.91, ls: 125325.20, cf: 22665.17, pe: 32.00 },
|
||||
{ li: 125325.21, ls: 375975.61, cf: 32691.18, pe: 34.00 },
|
||||
{ li: 375975.62, ls: null, cf: 117912.32, pe: 35.00 },
|
||||
],
|
||||
2026: [
|
||||
{ li: 0.01, ls: 844.59, cf: 0, pe: 1.92 },
|
||||
{ li: 844.60, ls: 7168.51, cf: 16.22, pe: 6.40 },
|
||||
{ li: 7168.52, ls: 12598.02, cf: 420.95, pe: 10.88 },
|
||||
{ li: 12598.03, ls: 14644.64, cf: 1011.68, pe: 16.00 },
|
||||
{ li: 14644.65, ls: 17533.64, cf: 1339.14, pe: 17.92 },
|
||||
{ li: 17533.65, ls: 35362.83, cf: 1856.84, pe: 21.36 },
|
||||
{ li: 35362.84, ls: 55736.68, cf: 5665.16, pe: 23.52 },
|
||||
{ li: 55736.69, ls: 106410.50, cf: 10457.09, pe: 30.00 },
|
||||
{ li: 106410.51, ls: 141880.66, cf: 25659.23, pe: 32.00 },
|
||||
{ li: 141880.67, ls: 425641.99, cf: 37009.69, pe: 34.00 },
|
||||
{ li: 425642.00, ls: null, cf: 133488.54, pe: 35.00 },
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,634 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "Plan" AS ENUM ('starter', 'business', 'business_ia', 'custom', 'enterprise');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "PlatformRole" AS ENUM ('platform_admin', 'platform_ti', 'platform_support', 'platform_sales', 'platform_finance');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "SatSyncType" AS ENUM ('initial', 'daily', 'incremental');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "SatSyncStatus" AS ENUM ('pending', 'running', 'completed', 'failed');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "CfdiSyncType" AS ENUM ('emitidos', 'recibidos');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "tenants" (
|
||||
"id" TEXT NOT NULL,
|
||||
"nombre" TEXT NOT NULL,
|
||||
"rfc" TEXT NOT NULL,
|
||||
"plan" "Plan" NOT NULL DEFAULT 'starter',
|
||||
"database_name" TEXT NOT NULL,
|
||||
"cfdi_limit" INTEGER NOT NULL DEFAULT 100,
|
||||
"users_limit" INTEGER NOT NULL DEFAULT 1,
|
||||
"active" BOOLEAN NOT NULL DEFAULT true,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"expires_at" TIMESTAMP(3),
|
||||
"trial_ends_at" TIMESTAMP(3),
|
||||
"facturapi_org_id" TEXT,
|
||||
"codigo_postal" VARCHAR(5),
|
||||
"calle" VARCHAR(255),
|
||||
"num_exterior" VARCHAR(20),
|
||||
"num_interior" VARCHAR(20),
|
||||
"colonia" VARCHAR(255),
|
||||
"ciudad" VARCHAR(100),
|
||||
"municipio" VARCHAR(100),
|
||||
"estado" VARCHAR(100),
|
||||
"telefono" VARCHAR(20),
|
||||
|
||||
CONSTRAINT "tenants_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "users" (
|
||||
"id" TEXT NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"password_hash" TEXT NOT NULL,
|
||||
"nombre" TEXT NOT NULL,
|
||||
"active" BOOLEAN NOT NULL DEFAULT true,
|
||||
"last_login" TIMESTAMP(3),
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"token_version" INTEGER NOT NULL DEFAULT 0,
|
||||
"last_tenant_id" TEXT,
|
||||
|
||||
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "tenant_memberships" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"tenant_id" TEXT NOT NULL,
|
||||
"rol_id" INTEGER NOT NULL,
|
||||
"is_owner" BOOLEAN NOT NULL DEFAULT false,
|
||||
"active" BOOLEAN NOT NULL DEFAULT true,
|
||||
"joined_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "tenant_memberships_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "roles" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"nombre" VARCHAR(20) NOT NULL,
|
||||
"descripcion" TEXT,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "roles_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "refresh_tokens" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"token" TEXT NOT NULL,
|
||||
"expires_at" TIMESTAMP(3) NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "refresh_tokens_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "password_reset_tokens" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"token" TEXT NOT NULL,
|
||||
"expires_at" TIMESTAMP(3) NOT NULL,
|
||||
"used_at" TIMESTAMP(3),
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "password_reset_tokens_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "regimenes" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"clave" VARCHAR(3) NOT NULL,
|
||||
"descripcion" TEXT NOT NULL,
|
||||
"tipo_persona" VARCHAR(20) NOT NULL,
|
||||
"activo" BOOLEAN NOT NULL DEFAULT true,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "regimenes_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "tenant_regimenes_ignorados" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"tenant_id" TEXT NOT NULL,
|
||||
"regimen_id" INTEGER NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "tenant_regimenes_ignorados_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "tenant_regimenes_activos" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"tenant_id" TEXT NOT NULL,
|
||||
"regimen_id" INTEGER NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "tenant_regimenes_activos_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "eventos_fiscales_catalogo" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"titulo" TEXT NOT NULL,
|
||||
"descripcion" TEXT,
|
||||
"tipo" VARCHAR(20) NOT NULL,
|
||||
"dia_base" INTEGER NOT NULL,
|
||||
"mes_relativo" INTEGER NOT NULL DEFAULT 1,
|
||||
"mes_fijo" INTEGER,
|
||||
"recurrencia" VARCHAR(20) NOT NULL DEFAULT 'mensual',
|
||||
"usa_extension_rfc" BOOLEAN NOT NULL DEFAULT false,
|
||||
"regimenes" TEXT NOT NULL DEFAULT 'todos',
|
||||
"condicion" VARCHAR(50),
|
||||
"activo" BOOLEAN NOT NULL DEFAULT true,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "eventos_fiscales_catalogo_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "lista_negra" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"rfc" VARCHAR(13) NOT NULL,
|
||||
"nombre" TEXT NOT NULL,
|
||||
"situacion" VARCHAR(30) NOT NULL,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "lista_negra_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "dias_inhabiles" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"fecha" DATE NOT NULL,
|
||||
"nombre" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "dias_inhabiles_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "isr_resico_tasas" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"anio" INTEGER NOT NULL,
|
||||
"monto_maximo" DECIMAL(18,2) NOT NULL,
|
||||
"porcentaje" DECIMAL(5,2) NOT NULL,
|
||||
|
||||
CONSTRAINT "isr_resico_tasas_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "isr_tarifas" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"anio" INTEGER NOT NULL,
|
||||
"limite_inferior" DECIMAL(18,2) NOT NULL,
|
||||
"limite_superior" DECIMAL(18,2),
|
||||
"cuota_fija" DECIMAL(18,2) NOT NULL,
|
||||
"porcentaje_excedente" DECIMAL(5,2) NOT NULL,
|
||||
|
||||
CONSTRAINT "isr_tarifas_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "coeficiente_utilidad" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"tenant_id" TEXT NOT NULL,
|
||||
"anio" INTEGER NOT NULL,
|
||||
"coeficiente" DECIMAL(10,4) NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "coeficiente_utilidad_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "fiel_credentials" (
|
||||
"id" TEXT NOT NULL,
|
||||
"tenant_id" TEXT NOT NULL,
|
||||
"rfc" VARCHAR(13) NOT NULL,
|
||||
"cer_data" BYTEA NOT NULL,
|
||||
"key_data" BYTEA NOT NULL,
|
||||
"key_password_encrypted" BYTEA NOT NULL,
|
||||
"cer_iv" BYTEA NOT NULL,
|
||||
"cer_tag" BYTEA NOT NULL,
|
||||
"key_iv" BYTEA NOT NULL,
|
||||
"key_tag" BYTEA NOT NULL,
|
||||
"password_iv" BYTEA NOT NULL,
|
||||
"password_tag" BYTEA NOT NULL,
|
||||
"serial_number" VARCHAR(50),
|
||||
"valid_from" TIMESTAMP(3) NOT NULL,
|
||||
"valid_until" TIMESTAMP(3) NOT NULL,
|
||||
"is_active" BOOLEAN NOT NULL DEFAULT true,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "fiel_credentials_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "subscriptions" (
|
||||
"id" TEXT NOT NULL,
|
||||
"tenant_id" TEXT NOT NULL,
|
||||
"plan" "Plan" NOT NULL,
|
||||
"mp_preapproval_id" TEXT,
|
||||
"status" TEXT NOT NULL DEFAULT 'pending',
|
||||
"amount" DECIMAL(10,2) NOT NULL,
|
||||
"frequency" TEXT NOT NULL DEFAULT 'monthly',
|
||||
"current_period_start" TIMESTAMP(3),
|
||||
"current_period_end" TIMESTAMP(3),
|
||||
"pending_plan" "Plan",
|
||||
"pending_frequency" TEXT,
|
||||
"pending_effective_at" TIMESTAMP(3),
|
||||
"upgrade_preference_id" TEXT,
|
||||
"upgrade_target_plan" "Plan",
|
||||
"upgrade_target_amount" DECIMAL(10,2),
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "subscriptions_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "user_platform_roles" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"role" "PlatformRole" NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"created_by" TEXT,
|
||||
|
||||
CONSTRAINT "user_platform_roles_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "audit_log" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT,
|
||||
"tenant_id" TEXT,
|
||||
"action" VARCHAR(64) NOT NULL,
|
||||
"entity_type" VARCHAR(32),
|
||||
"entity_id" TEXT,
|
||||
"metadata" JSONB,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "audit_log_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "trial_usages" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"rfc" VARCHAR(13) NOT NULL,
|
||||
"tenant_id" TEXT,
|
||||
"started_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "trial_usages_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "plan_prices" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"plan" "Plan" NOT NULL,
|
||||
"frequency" TEXT NOT NULL,
|
||||
"amount" DECIMAL(10,2) NOT NULL,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "plan_prices_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "payments" (
|
||||
"id" TEXT NOT NULL,
|
||||
"tenant_id" TEXT NOT NULL,
|
||||
"subscription_id" TEXT,
|
||||
"mp_payment_id" TEXT,
|
||||
"amount" DECIMAL(10,2) NOT NULL,
|
||||
"status" TEXT NOT NULL DEFAULT 'pending',
|
||||
"payment_method" TEXT,
|
||||
"paid_at" TIMESTAMP(3),
|
||||
"facturapi_invoice_id" TEXT,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "payments_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "sat_sync_jobs" (
|
||||
"id" TEXT NOT NULL,
|
||||
"tenant_id" TEXT NOT NULL,
|
||||
"type" "SatSyncType" NOT NULL,
|
||||
"status" "SatSyncStatus" NOT NULL DEFAULT 'pending',
|
||||
"date_from" DATE NOT NULL,
|
||||
"date_to" DATE NOT NULL,
|
||||
"cfdi_type" "CfdiSyncType",
|
||||
"sat_request_id" VARCHAR(50),
|
||||
"sat_package_ids" TEXT[],
|
||||
"cfdis_found" INTEGER NOT NULL DEFAULT 0,
|
||||
"cfdis_downloaded" INTEGER NOT NULL DEFAULT 0,
|
||||
"cfdis_inserted" INTEGER NOT NULL DEFAULT 0,
|
||||
"cfdis_updated" INTEGER NOT NULL DEFAULT 0,
|
||||
"progress_percent" INTEGER NOT NULL DEFAULT 0,
|
||||
"error_message" TEXT,
|
||||
"started_at" TIMESTAMP(3),
|
||||
"completed_at" TIMESTAMP(3),
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"retry_count" INTEGER NOT NULL DEFAULT 0,
|
||||
"next_retry_at" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "sat_sync_jobs_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "cat_forma_pago" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"clave" VARCHAR(2) NOT NULL,
|
||||
"descripcion" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "cat_forma_pago_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "cat_metodo_pago" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"clave" VARCHAR(3) NOT NULL,
|
||||
"descripcion" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "cat_metodo_pago_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "cat_uso_cfdi" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"clave" VARCHAR(4) NOT NULL,
|
||||
"descripcion" TEXT NOT NULL,
|
||||
"persona_fisica" BOOLEAN NOT NULL DEFAULT true,
|
||||
"persona_moral" BOOLEAN NOT NULL DEFAULT true,
|
||||
|
||||
CONSTRAINT "cat_uso_cfdi_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "cat_moneda" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"clave" VARCHAR(3) NOT NULL,
|
||||
"descripcion" TEXT NOT NULL,
|
||||
"decimales" INTEGER NOT NULL DEFAULT 2,
|
||||
|
||||
CONSTRAINT "cat_moneda_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "cat_clave_unidad" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"clave" VARCHAR(10) NOT NULL,
|
||||
"descripcion" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "cat_clave_unidad_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "cat_clave_prod_serv" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"clave" VARCHAR(8) NOT NULL,
|
||||
"descripcion" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "cat_clave_prod_serv_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "cat_objeto_imp" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"clave" VARCHAR(2) NOT NULL,
|
||||
"descripcion" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "cat_objeto_imp_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "cat_tipo_relacion" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"clave" VARCHAR(2) NOT NULL,
|
||||
"descripcion" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "cat_tipo_relacion_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "cat_exportacion" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"clave" VARCHAR(2) NOT NULL,
|
||||
"descripcion" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "cat_exportacion_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "timbre_suscripciones" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"tenant_id" TEXT NOT NULL,
|
||||
"tipo" VARCHAR(10) NOT NULL,
|
||||
"timbres_limite" INTEGER NOT NULL,
|
||||
"timbres_usados" INTEGER NOT NULL DEFAULT 0,
|
||||
"periodo_inicio" DATE NOT NULL,
|
||||
"periodo_fin" DATE NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "timbre_suscripciones_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "tenants_rfc_key" ON "tenants"("rfc");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "tenants_database_name_key" ON "tenants"("database_name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "tenant_memberships_user_id_active_idx" ON "tenant_memberships"("user_id", "active");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "tenant_memberships_tenant_id_active_idx" ON "tenant_memberships"("tenant_id", "active");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "tenant_memberships_user_id_tenant_id_key" ON "tenant_memberships"("user_id", "tenant_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "roles_nombre_key" ON "roles"("nombre");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "refresh_tokens_token_key" ON "refresh_tokens"("token");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "password_reset_tokens_token_key" ON "password_reset_tokens"("token");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "password_reset_tokens_user_id_idx" ON "password_reset_tokens"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "password_reset_tokens_expires_at_idx" ON "password_reset_tokens"("expires_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "regimenes_clave_key" ON "regimenes"("clave");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "tenant_regimenes_ignorados_tenant_id_regimen_id_key" ON "tenant_regimenes_ignorados"("tenant_id", "regimen_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "tenant_regimenes_activos_tenant_id_regimen_id_key" ON "tenant_regimenes_activos"("tenant_id", "regimen_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "lista_negra_rfc_key" ON "lista_negra"("rfc");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "lista_negra_rfc_idx" ON "lista_negra"("rfc");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "dias_inhabiles_fecha_key" ON "dias_inhabiles"("fecha");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "isr_resico_tasas_anio_monto_maximo_key" ON "isr_resico_tasas"("anio", "monto_maximo");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "isr_tarifas_anio_limite_inferior_key" ON "isr_tarifas"("anio", "limite_inferior");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "coeficiente_utilidad_tenant_id_anio_key" ON "coeficiente_utilidad"("tenant_id", "anio");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "fiel_credentials_tenant_id_key" ON "fiel_credentials"("tenant_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "subscriptions_tenant_id_idx" ON "subscriptions"("tenant_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "subscriptions_status_idx" ON "subscriptions"("status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "subscriptions_pending_effective_at_idx" ON "subscriptions"("pending_effective_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "user_platform_roles_role_idx" ON "user_platform_roles"("role");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "user_platform_roles_user_id_role_key" ON "user_platform_roles"("user_id", "role");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "audit_log_user_id_created_at_idx" ON "audit_log"("user_id", "created_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "audit_log_tenant_id_created_at_idx" ON "audit_log"("tenant_id", "created_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "audit_log_action_created_at_idx" ON "audit_log"("action", "created_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "audit_log_entity_type_entity_id_idx" ON "audit_log"("entity_type", "entity_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "trial_usages_rfc_key" ON "trial_usages"("rfc");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "plan_prices_plan_frequency_key" ON "plan_prices"("plan", "frequency");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "payments_tenant_id_idx" ON "payments"("tenant_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "payments_subscription_id_idx" ON "payments"("subscription_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "sat_sync_jobs_tenant_id_idx" ON "sat_sync_jobs"("tenant_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "sat_sync_jobs_status_idx" ON "sat_sync_jobs"("status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "sat_sync_jobs_status_next_retry_at_idx" ON "sat_sync_jobs"("status", "next_retry_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "cat_forma_pago_clave_key" ON "cat_forma_pago"("clave");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "cat_metodo_pago_clave_key" ON "cat_metodo_pago"("clave");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "cat_uso_cfdi_clave_key" ON "cat_uso_cfdi"("clave");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "cat_moneda_clave_key" ON "cat_moneda"("clave");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "cat_clave_unidad_clave_key" ON "cat_clave_unidad"("clave");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "cat_clave_prod_serv_clave_key" ON "cat_clave_prod_serv"("clave");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "cat_clave_prod_serv_descripcion_idx" ON "cat_clave_prod_serv"("descripcion");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "cat_objeto_imp_clave_key" ON "cat_objeto_imp"("clave");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "cat_tipo_relacion_clave_key" ON "cat_tipo_relacion"("clave");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "cat_exportacion_clave_key" ON "cat_exportacion"("clave");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "timbre_suscripciones_tenant_id_key" ON "timbre_suscripciones"("tenant_id");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "tenant_memberships" ADD CONSTRAINT "tenant_memberships_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "tenant_memberships" ADD CONSTRAINT "tenant_memberships_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "tenant_memberships" ADD CONSTRAINT "tenant_memberships_rol_id_fkey" FOREIGN KEY ("rol_id") REFERENCES "roles"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "password_reset_tokens" ADD CONSTRAINT "password_reset_tokens_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "tenant_regimenes_ignorados" ADD CONSTRAINT "tenant_regimenes_ignorados_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "tenant_regimenes_ignorados" ADD CONSTRAINT "tenant_regimenes_ignorados_regimen_id_fkey" FOREIGN KEY ("regimen_id") REFERENCES "regimenes"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "tenant_regimenes_activos" ADD CONSTRAINT "tenant_regimenes_activos_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "tenant_regimenes_activos" ADD CONSTRAINT "tenant_regimenes_activos_regimen_id_fkey" FOREIGN KEY ("regimen_id") REFERENCES "regimenes"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "coeficiente_utilidad" ADD CONSTRAINT "coeficiente_utilidad_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "fiel_credentials" ADD CONSTRAINT "fiel_credentials_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "subscriptions" ADD CONSTRAINT "subscriptions_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "user_platform_roles" ADD CONSTRAINT "user_platform_roles_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "payments" ADD CONSTRAINT "payments_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "payments" ADD CONSTRAINT "payments_subscription_id_fkey" FOREIGN KEY ("subscription_id") REFERENCES "subscriptions"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "sat_sync_jobs" ADD CONSTRAINT "sat_sync_jobs_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "timbre_suscripciones" ADD CONSTRAINT "timbre_suscripciones_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "PaymentKind" AS ENUM ('subscription', 'timbres_pack');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "payments" ADD COLUMN "kind" "PaymentKind" NOT NULL DEFAULT 'subscription';
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "timbre_paquetes_catalogo" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"cantidad" INTEGER NOT NULL,
|
||||
"precio" DECIMAL(10,2) NOT NULL,
|
||||
"active" BOOLEAN NOT NULL DEFAULT true,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "timbre_paquetes_catalogo_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "timbre_paquetes" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"tenant_id" TEXT NOT NULL,
|
||||
"payment_id" TEXT,
|
||||
"cantidad" INTEGER NOT NULL,
|
||||
"usados" INTEGER NOT NULL DEFAULT 0,
|
||||
"precio" DECIMAL(10,2) NOT NULL,
|
||||
"adquirido_en" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"expira_en" TIMESTAMP(3) NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "timbre_paquetes_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "timbre_paquetes_catalogo_cantidad_key" ON "timbre_paquetes_catalogo"("cantidad");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "timbre_paquetes_payment_id_key" ON "timbre_paquetes"("payment_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "timbre_paquetes_tenant_id_expira_en_idx" ON "timbre_paquetes"("tenant_id", "expira_en");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "timbre_paquetes" ADD CONSTRAINT "timbre_paquetes_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "timbre_paquetes" ADD CONSTRAINT "timbre_paquetes_payment_id_fkey" FOREIGN KEY ("payment_id") REFERENCES "payments"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,16 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "VerticalProfile" AS ENUM ('CONTABLE', 'JURIDICO', 'ARQUITECTURA');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "DbMode" AS ENUM ('BYO', 'MANAGED');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "tenants" ADD COLUMN "connector_last_seen" TIMESTAMP(3),
|
||||
ADD COLUMN "connector_token_enc" TEXT,
|
||||
ADD COLUMN "connector_tunnel_hostname" TEXT,
|
||||
ADD COLUMN "connector_version" VARCHAR(20),
|
||||
ADD COLUMN "db_connection_enc" TEXT,
|
||||
ADD COLUMN "db_connection_iv" TEXT,
|
||||
ADD COLUMN "db_mode" "DbMode",
|
||||
ADD COLUMN "db_schema_version" INTEGER NOT NULL DEFAULT 0,
|
||||
ADD COLUMN "vertical_profile" "VerticalProfile";
|
||||
@@ -0,0 +1,35 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "plan_catalogo" (
|
||||
"id" TEXT NOT NULL,
|
||||
"codename" VARCHAR(50) NOT NULL,
|
||||
"nombre" TEXT NOT NULL,
|
||||
"verticalProfile" "VerticalProfile" NOT NULL,
|
||||
"precio_base" DECIMAL(10,2) NOT NULL,
|
||||
"frecuencia" VARCHAR(10) NOT NULL,
|
||||
"limits" JSONB NOT NULL,
|
||||
"active" BOOLEAN NOT NULL DEFAULT true,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "plan_catalogo_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "plan_addon_catalogo" (
|
||||
"id" TEXT NOT NULL,
|
||||
"codename" VARCHAR(50) NOT NULL,
|
||||
"nombre" TEXT NOT NULL,
|
||||
"verticalProfile" "VerticalProfile",
|
||||
"precio" DECIMAL(10,2) NOT NULL,
|
||||
"frecuencia" VARCHAR(10) NOT NULL,
|
||||
"delta" JSONB NOT NULL,
|
||||
"active" BOOLEAN NOT NULL DEFAULT true,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "plan_addon_catalogo_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "plan_catalogo_codename_key" ON "plan_catalogo"("codename");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "plan_addon_catalogo_codename_key" ON "plan_addon_catalogo"("codename");
|
||||
@@ -0,0 +1,28 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "subscription_addons" (
|
||||
"id" TEXT NOT NULL,
|
||||
"subscription_id" TEXT NOT NULL,
|
||||
"plan_addon_catalogo_id" TEXT NOT NULL,
|
||||
"mp_preapproval_id" TEXT,
|
||||
"status" TEXT NOT NULL DEFAULT 'pending',
|
||||
"quantity" INTEGER NOT NULL DEFAULT 1,
|
||||
"amount" DECIMAL(10,2) NOT NULL,
|
||||
"current_period_start" TIMESTAMP(3),
|
||||
"current_period_end" TIMESTAMP(3),
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "subscription_addons_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "subscription_addons_subscription_id_idx" ON "subscription_addons"("subscription_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "subscription_addons_subscription_id_plan_addon_catalogo_id_key" ON "subscription_addons"("subscription_id", "plan_addon_catalogo_id");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "subscription_addons" ADD CONSTRAINT "subscription_addons_subscription_id_fkey" FOREIGN KEY ("subscription_id") REFERENCES "subscriptions"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "subscription_addons" ADD CONSTRAINT "subscription_addons_plan_addon_catalogo_id_fkey" FOREIGN KEY ("plan_addon_catalogo_id") REFERENCES "plan_addon_catalogo"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,19 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "connector_heartbeats" (
|
||||
"id" TEXT NOT NULL,
|
||||
"tenant_id" TEXT NOT NULL,
|
||||
"timestamp" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"latency_ms" INTEGER NOT NULL,
|
||||
"version" VARCHAR(20) NOT NULL,
|
||||
"pg_version" VARCHAR(50),
|
||||
"status" VARCHAR(20) NOT NULL,
|
||||
"error_msg" TEXT,
|
||||
|
||||
CONSTRAINT "connector_heartbeats_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "connector_heartbeats_tenant_id_timestamp_idx" ON "connector_heartbeats"("tenant_id", "timestamp");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "connector_heartbeats" ADD CONSTRAINT "connector_heartbeats_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "sat_sync_jobs" ADD COLUMN "contribuyente_id" TEXT;
|
||||
@@ -0,0 +1,10 @@
|
||||
-- AlterEnum
|
||||
-- This migration adds more than one value to an enum.
|
||||
-- With PostgreSQL versions 11 and earlier, this is not possible
|
||||
-- in a single migration. This can be worked around by creating
|
||||
-- multiple migrations, each migration adding only one value to
|
||||
-- the enum.
|
||||
|
||||
|
||||
ALTER TYPE "Plan" ADD VALUE 'business_control';
|
||||
ALTER TYPE "Plan" ADD VALUE 'business_cloud';
|
||||
@@ -0,0 +1,18 @@
|
||||
-- Add-ons por contribuyente: permite que SubscriptionAddon se asocie a un
|
||||
-- contribuyente específico (ej. Lolita IA $250/mes activable por RFC) además
|
||||
-- de los add-ons a nivel tenant (modulos, +RFCs, +timbres) que tienen
|
||||
-- contribuyente_id = NULL.
|
||||
|
||||
ALTER TABLE "subscription_addons"
|
||||
ADD COLUMN "contribuyente_id" TEXT;
|
||||
|
||||
-- Eliminar el UNIQUE (subscription_id, plan_addon_catalogo_id). Ahora el
|
||||
-- mismo add-on (p. ej. lolita_ia_contribuyente) puede tener N filas por
|
||||
-- subscription, una por cada contribuyente que lo contrate.
|
||||
ALTER TABLE "subscription_addons"
|
||||
DROP CONSTRAINT IF EXISTS "subscription_addons_subscription_id_plan_addon_catalogo_id_key";
|
||||
|
||||
-- Índice por (subscription_id, contribuyente_id) para lookups rápidos
|
||||
-- "qué add-ons tiene este contribuyente"
|
||||
CREATE INDEX IF NOT EXISTS "subscription_addons_subscription_id_contribuyente_id_idx"
|
||||
ON "subscription_addons"("subscription_id", "contribuyente_id");
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "Plan" ADD VALUE 'mi_empresa';
|
||||
@@ -0,0 +1,18 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "despacho_plan_prices" (
|
||||
"plan" TEXT NOT NULL,
|
||||
"monthly" DECIMAL(10,2),
|
||||
"first_year" DECIMAL(10,2) NOT NULL,
|
||||
"renewal" DECIMAL(10,2) NOT NULL,
|
||||
"permite_monthly" BOOLEAN NOT NULL DEFAULT false,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "despacho_plan_prices_pkey" PRIMARY KEY ("plan")
|
||||
);
|
||||
|
||||
-- Seed inicial con valores actuales del catálogo `DESPACHO_PLAN_PRICES`.
|
||||
INSERT INTO "despacho_plan_prices" ("plan", "monthly", "first_year", "renewal", "permite_monthly", "updated_at") VALUES
|
||||
('mi_empresa', 580, 5800, 5800, true, NOW()),
|
||||
('mi_empresa_plus', 900, 9000, 9000, true, NOW()),
|
||||
('business_control', NULL, 25850, 25850, false, NOW()),
|
||||
('business_cloud', NULL, 43000, 43000, false, NOW());
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
@@ -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";
|
||||
@@ -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');
|
||||
@@ -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;
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
3
apps/api/prisma/migrations/migration_lock.toml
Normal file
3
apps/api/prisma/migrations/migration_lock.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (i.e. Git)
|
||||
provider = "postgresql"
|
||||
760
apps/api/prisma/schema.prisma
Normal file
760
apps/api/prisma/schema.prisma
Normal 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
528
apps/api/prisma/seed.ts
Normal 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();
|
||||
});
|
||||
37
apps/api/scripts/apply-migration-042.ts
Normal file
37
apps/api/scripts/apply-migration-042.ts
Normal 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); });
|
||||
158
apps/api/scripts/backfill-cfdi-contribuyente.ts
Normal file
158
apps/api/scripts/backfill-cfdi-contribuyente.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* Backfill de cfdis.contribuyente_id para los despachos.
|
||||
*
|
||||
* Asocia CFDIs huérfanos (contribuyente_id NULL) con el contribuyente cuyo RFC
|
||||
* coincide con rfc_emisor (si type='EMITIDO') o rfc_receptor (si type='RECIBIDO').
|
||||
*
|
||||
* Causa raíz: retry path de sat.service.ts construía SyncContext sin
|
||||
* contribuyenteId (bug fixed 2026-04-20).
|
||||
*
|
||||
* Idempotente: solo actualiza filas con contribuyente_id IS NULL y match único
|
||||
* por RFC. Si no hay contribuyentes en el tenant (Horux360 clásico), no-op.
|
||||
*
|
||||
* Uso:
|
||||
* pnpm --filter @horux/api exec tsx scripts/backfill-cfdi-contribuyente.ts # ejecuta
|
||||
* pnpm --filter @horux/api exec tsx scripts/backfill-cfdi-contribuyente.ts --dry # reporta sin escribir
|
||||
*/
|
||||
import { prisma, tenantDb } from '../src/config/database.js';
|
||||
|
||||
const DRY_RUN = process.argv.includes('--dry') || process.argv.includes('--dry-run');
|
||||
|
||||
interface PerTenantResult {
|
||||
tenantId: string;
|
||||
rfc: string;
|
||||
databaseName: string;
|
||||
contribuyentesCount: number;
|
||||
updated: number;
|
||||
perContribuyente: Array<{ rfc: string; entidadId: string; rows: number }>;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
async function backfillTenant(
|
||||
tenantId: string,
|
||||
rfc: string,
|
||||
databaseName: string,
|
||||
): Promise<PerTenantResult> {
|
||||
const result: PerTenantResult = {
|
||||
tenantId,
|
||||
rfc,
|
||||
databaseName,
|
||||
contribuyentesCount: 0,
|
||||
updated: 0,
|
||||
perContribuyente: [],
|
||||
};
|
||||
|
||||
const pool = await tenantDb.getPool(tenantId, databaseName);
|
||||
|
||||
const { rows: contribs } = await pool.query<{ entidad_id: string; rfc: string }>(
|
||||
`SELECT entidad_id, rfc FROM contribuyentes`,
|
||||
);
|
||||
result.contribuyentesCount = contribs.length;
|
||||
if (contribs.length === 0) return result;
|
||||
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
const sql = `
|
||||
UPDATE cfdis c
|
||||
SET contribuyente_id = cnt.entidad_id
|
||||
FROM contribuyentes cnt
|
||||
WHERE c.contribuyente_id IS NULL
|
||||
AND (
|
||||
(c.type = 'EMITIDO' AND cnt.rfc = c.rfc_emisor) OR
|
||||
(c.type = 'RECIBIDO' AND cnt.rfc = c.rfc_receptor)
|
||||
)
|
||||
RETURNING cnt.entidad_id as "entidadId", cnt.rfc as "rfcContrib"
|
||||
`;
|
||||
|
||||
const { rows: updated } = await client.query<{ entidadId: string; rfcContrib: string }>(sql);
|
||||
result.updated = updated.length;
|
||||
|
||||
const byContrib = new Map<string, { rfc: string; rows: number }>();
|
||||
for (const row of updated) {
|
||||
const cur = byContrib.get(row.entidadId);
|
||||
if (cur) cur.rows += 1;
|
||||
else byContrib.set(row.entidadId, { rfc: row.rfcContrib, rows: 1 });
|
||||
}
|
||||
result.perContribuyente = Array.from(byContrib.entries()).map(([entidadId, v]) => ({
|
||||
entidadId,
|
||||
rfc: v.rfc,
|
||||
rows: v.rows,
|
||||
}));
|
||||
|
||||
if (DRY_RUN) {
|
||||
await client.query('ROLLBACK');
|
||||
} else {
|
||||
await client.query('COMMIT');
|
||||
}
|
||||
} catch (err: any) {
|
||||
await client.query('ROLLBACK').catch(() => {});
|
||||
result.error = err?.message || String(err);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`=== Backfill cfdis.contribuyente_id ${DRY_RUN ? '(DRY RUN — no writes)' : ''} ===\n`);
|
||||
|
||||
const tenants = await prisma.tenant.findMany({
|
||||
where: { active: true },
|
||||
select: { id: true, rfc: true, databaseName: true },
|
||||
orderBy: { rfc: 'asc' },
|
||||
});
|
||||
|
||||
console.log(`Tenants activos: ${tenants.length}\n`);
|
||||
|
||||
const results: PerTenantResult[] = [];
|
||||
for (const t of tenants) {
|
||||
process.stdout.write(`[${t.rfc}] (${t.databaseName}) ... `);
|
||||
try {
|
||||
const r = await backfillTenant(t.id, t.rfc, t.databaseName);
|
||||
results.push(r);
|
||||
if (r.error) {
|
||||
console.log(`ERROR: ${r.error}`);
|
||||
} else if (r.contribuyentesCount === 0) {
|
||||
console.log(`sin contribuyentes (skip)`);
|
||||
} else {
|
||||
console.log(`${r.contribuyentesCount} contribs, ${r.updated} CFDIs backfill`);
|
||||
for (const pc of r.perContribuyente) {
|
||||
console.log(` ${pc.rfc}: ${pc.rows}`);
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.log(`FATAL: ${err?.message || err}`);
|
||||
results.push({
|
||||
tenantId: t.id,
|
||||
rfc: t.rfc,
|
||||
databaseName: t.databaseName,
|
||||
contribuyentesCount: 0,
|
||||
updated: 0,
|
||||
perContribuyente: [],
|
||||
error: err?.message || String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const totalUpdated = results.reduce((s, r) => s + r.updated, 0);
|
||||
const tenantsTouched = results.filter(r => r.updated > 0).length;
|
||||
const tenantsFailed = results.filter(r => r.error).length;
|
||||
|
||||
console.log(`\n=== Resumen ===`);
|
||||
console.log(` Tenants procesados: ${results.length}`);
|
||||
console.log(` Tenants con backfill: ${tenantsTouched}`);
|
||||
console.log(` CFDIs actualizados: ${totalUpdated}${DRY_RUN ? ' (rolled back)' : ''}`);
|
||||
if (tenantsFailed > 0) console.log(` Tenants con error: ${tenantsFailed}`);
|
||||
|
||||
await prisma.$disconnect();
|
||||
process.exit(tenantsFailed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
main().catch(async (err) => {
|
||||
console.error('Fatal:', err);
|
||||
await prisma.$disconnect().catch(() => {});
|
||||
process.exit(1);
|
||||
});
|
||||
209
apps/api/scripts/backfill-cfdis-relaciones.ts
Normal file
209
apps/api/scripts/backfill-cfdis-relaciones.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* Backfill de cfdis.cfdi_tipo_relacion + cfdis.cfdis_relacionados desde
|
||||
* xml_original para CFDIs pre-migración 032.
|
||||
*
|
||||
* Criterio: WHERE xml_original IS NOT NULL AND cfdi_tipo_relacion IS NULL.
|
||||
* Re-usa `parseXml()` para mantener la lógica de extracción idéntica al sync.
|
||||
* Solo escribe si el parser extrae `cfdiTipoRelacion` no-nulo — los CFDIs sin
|
||||
* CfdiRelacionados se siguen dejando con NULL (distinguible de "no procesado"
|
||||
* via el filtro `cfdi_tipo_relacion IS NULL` porque el WHERE al final del run
|
||||
* ya no los va a volver a tocar — pero cada invocación empieza desde el mismo
|
||||
* filtro, por eso es idempotente: los sin-relación se re-parsean cada vez pero
|
||||
* no se escribe nada).
|
||||
*
|
||||
* Uso:
|
||||
* pnpm --filter @horux/api exec tsx scripts/backfill-cfdis-relaciones.ts # ejecuta
|
||||
* pnpm --filter @horux/api exec tsx scripts/backfill-cfdis-relaciones.ts --dry # reporta sin escribir
|
||||
*/
|
||||
import { prisma, tenantDb } from '../src/config/database.js';
|
||||
import { parseXml } from '../src/services/sat/sat-parser.service.js';
|
||||
|
||||
const DRY_RUN = process.argv.includes('--dry') || process.argv.includes('--dry-run');
|
||||
|
||||
interface PerTenantResult {
|
||||
tenantId: string;
|
||||
rfc: string;
|
||||
databaseName: string;
|
||||
scanned: number;
|
||||
parsedOk: number;
|
||||
parseFailed: number;
|
||||
withRelation: number;
|
||||
updated: number;
|
||||
byTipoRelacion: Record<string, number>;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
async function backfillTenant(
|
||||
tenantId: string,
|
||||
rfc: string,
|
||||
databaseName: string,
|
||||
): Promise<PerTenantResult> {
|
||||
const result: PerTenantResult = {
|
||||
tenantId,
|
||||
rfc,
|
||||
databaseName,
|
||||
scanned: 0,
|
||||
parsedOk: 0,
|
||||
parseFailed: 0,
|
||||
withRelation: 0,
|
||||
updated: 0,
|
||||
byTipoRelacion: {},
|
||||
};
|
||||
|
||||
const pool = await tenantDb.getPool(tenantId, databaseName);
|
||||
|
||||
const { rows } = await pool.query<{
|
||||
id: number;
|
||||
uuid: string;
|
||||
type: string;
|
||||
xml_original: string | null;
|
||||
}>(
|
||||
`SELECT id, uuid, type, xml_original
|
||||
FROM cfdis
|
||||
WHERE xml_original IS NOT NULL
|
||||
AND cfdi_tipo_relacion IS NULL
|
||||
ORDER BY id`,
|
||||
);
|
||||
|
||||
result.scanned = rows.length;
|
||||
if (rows.length === 0) return result;
|
||||
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
for (const row of rows) {
|
||||
if (!row.xml_original) continue;
|
||||
|
||||
const downloadType = row.type === 'EMITIDO' ? 'emitidos' : 'recibidos';
|
||||
let parsed;
|
||||
try {
|
||||
parsed = parseXml(row.xml_original, downloadType);
|
||||
} catch {
|
||||
result.parseFailed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!parsed) {
|
||||
result.parseFailed++;
|
||||
continue;
|
||||
}
|
||||
result.parsedOk++;
|
||||
|
||||
if (!parsed.cfdiTipoRelacion) continue;
|
||||
|
||||
result.withRelation++;
|
||||
const tr = parsed.cfdiTipoRelacion;
|
||||
result.byTipoRelacion[tr] = (result.byTipoRelacion[tr] || 0) + 1;
|
||||
|
||||
await client.query(
|
||||
`UPDATE cfdis
|
||||
SET cfdi_tipo_relacion = $2,
|
||||
cfdis_relacionados = $3,
|
||||
actualizado_en = NOW()
|
||||
WHERE id = $1`,
|
||||
[row.id, parsed.cfdiTipoRelacion, parsed.cfdisRelacionados],
|
||||
);
|
||||
result.updated++;
|
||||
}
|
||||
|
||||
if (DRY_RUN) {
|
||||
await client.query('ROLLBACK');
|
||||
} else {
|
||||
await client.query('COMMIT');
|
||||
}
|
||||
} catch (err: any) {
|
||||
await client.query('ROLLBACK').catch(() => {});
|
||||
result.error = err?.message || String(err);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`=== Backfill cfdis CfdiRelacionados ${DRY_RUN ? '(DRY RUN — no writes)' : ''} ===\n`);
|
||||
|
||||
const tenants = await prisma.tenant.findMany({
|
||||
where: { active: true },
|
||||
select: { id: true, rfc: true, databaseName: true },
|
||||
orderBy: { rfc: 'asc' },
|
||||
});
|
||||
|
||||
console.log(`Tenants activos: ${tenants.length}\n`);
|
||||
|
||||
const results: PerTenantResult[] = [];
|
||||
for (const t of tenants) {
|
||||
process.stdout.write(`[${t.rfc}] (${t.databaseName}) ... `);
|
||||
try {
|
||||
const r = await backfillTenant(t.id, t.rfc, t.databaseName);
|
||||
results.push(r);
|
||||
if (r.error) {
|
||||
console.log(`ERROR: ${r.error}`);
|
||||
} else if (r.scanned === 0) {
|
||||
console.log(`sin CFDIs candidatos (skip)`);
|
||||
} else {
|
||||
const tiposStr = Object.entries(r.byTipoRelacion)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([tr, n]) => `${tr}:${n}`)
|
||||
.join(', ');
|
||||
console.log(
|
||||
`scan=${r.scanned} parsed=${r.parsedOk} fail=${r.parseFailed} rel=${r.withRelation} upd=${r.updated}${
|
||||
tiposStr ? ` [${tiposStr}]` : ''
|
||||
}`,
|
||||
);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.log(`FATAL: ${err?.message || err}`);
|
||||
results.push({
|
||||
tenantId: t.id,
|
||||
rfc: t.rfc,
|
||||
databaseName: t.databaseName,
|
||||
scanned: 0,
|
||||
parsedOk: 0,
|
||||
parseFailed: 0,
|
||||
withRelation: 0,
|
||||
updated: 0,
|
||||
byTipoRelacion: {},
|
||||
error: err?.message || String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const totalScanned = results.reduce((s, r) => s + r.scanned, 0);
|
||||
const totalUpdated = results.reduce((s, r) => s + r.updated, 0);
|
||||
const totalParseFailed = results.reduce((s, r) => s + r.parseFailed, 0);
|
||||
const tenantsTouched = results.filter(r => r.updated > 0).length;
|
||||
const tenantsFailed = results.filter(r => r.error).length;
|
||||
|
||||
const tiposGlobales: Record<string, number> = {};
|
||||
for (const r of results) {
|
||||
for (const [tr, n] of Object.entries(r.byTipoRelacion)) {
|
||||
tiposGlobales[tr] = (tiposGlobales[tr] || 0) + n;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n=== Resumen ===`);
|
||||
console.log(` Tenants procesados: ${results.length}`);
|
||||
console.log(` Tenants con backfill: ${tenantsTouched}`);
|
||||
console.log(` CFDIs escaneados: ${totalScanned}`);
|
||||
console.log(` CFDIs actualizados: ${totalUpdated}${DRY_RUN ? ' (rolled back)' : ''}`);
|
||||
if (totalParseFailed > 0) console.log(` CFDIs parse falló: ${totalParseFailed}`);
|
||||
if (tenantsFailed > 0) console.log(` Tenants con error: ${tenantsFailed}`);
|
||||
if (Object.keys(tiposGlobales).length > 0) {
|
||||
console.log(` Desglose TipoRelacion:`);
|
||||
for (const [tr, n] of Object.entries(tiposGlobales).sort((a, b) => b[1] - a[1])) {
|
||||
console.log(` ${tr}: ${n}`);
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.$disconnect();
|
||||
process.exit(tenantsFailed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
main().catch(async (err) => {
|
||||
console.error('Fatal:', err);
|
||||
await prisma.$disconnect().catch(() => {});
|
||||
process.exit(1);
|
||||
});
|
||||
126
apps/api/scripts/backfill-facturapi-cfdis.ts
Normal file
126
apps/api/scripts/backfill-facturapi-cfdis.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Backfill one-shot: completa los campos de emisor/subtotal/IVA/XML en las
|
||||
* filas de `cfdis` con `source='facturapi'` que fueron insertadas por la
|
||||
* versión buggy del controller (previo al fix 2026-04-24).
|
||||
*
|
||||
* Descarga el XML real de Facturapi, lo parsea con el mismo parser SAT,
|
||||
* upsertea la fila de `rfcs` del emisor, y actualiza la fila de `cfdis`.
|
||||
*/
|
||||
import { prisma, tenantDb } from '../src/config/database.js';
|
||||
import { downloadXmlContribuyente } from '../src/services/contribuyente-facturapi.service.js';
|
||||
import * as facturapiService from '../src/services/facturapi.service.js';
|
||||
import { parseXml } from '../src/services/sat/sat-parser.service.js';
|
||||
|
||||
const TENANT_RFC = process.argv[2] || 'DESPACHO_MO3NI6U8_B9VGG';
|
||||
|
||||
async function main() {
|
||||
const tenant = await prisma.tenant.findFirst({
|
||||
where: { rfc: TENANT_RFC },
|
||||
select: { id: true, databaseName: true },
|
||||
});
|
||||
if (!tenant) {
|
||||
console.log(`Tenant ${TENANT_RFC} no encontrado`);
|
||||
return;
|
||||
}
|
||||
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
|
||||
|
||||
const { rows: pendientes } = await pool.query<{
|
||||
id: number;
|
||||
uuid: string;
|
||||
facturapi_id: string;
|
||||
contribuyente_id: string | null;
|
||||
rfc_emisor: string | null;
|
||||
}>(
|
||||
`SELECT id, uuid, facturapi_id, contribuyente_id, rfc_emisor
|
||||
FROM cfdis
|
||||
WHERE source = 'facturapi'
|
||||
AND (COALESCE(rfc_emisor, '') = '' OR xml_original IS NULL OR subtotal = 0)
|
||||
ORDER BY fecha_emision ASC`,
|
||||
);
|
||||
|
||||
console.log(`Encontradas ${pendientes.length} CFDIs Facturapi a backfillear en ${TENANT_RFC}\n`);
|
||||
|
||||
let ok = 0;
|
||||
let fail = 0;
|
||||
|
||||
for (const row of pendientes) {
|
||||
try {
|
||||
console.log(`\n[${row.uuid}] facturapi_id=${row.facturapi_id} contrib=${row.contribuyente_id}`);
|
||||
|
||||
const xmlBuffer = row.contribuyente_id
|
||||
? await downloadXmlContribuyente(pool, row.contribuyente_id, row.facturapi_id)
|
||||
: await facturapiService.downloadXml(tenant.id, row.facturapi_id);
|
||||
const xmlString = xmlBuffer.toString('utf-8');
|
||||
const parsed = parseXml(xmlString, 'emitidos');
|
||||
if (!parsed) {
|
||||
console.log(` ⚠️ Parser retornó null — skip`);
|
||||
fail++;
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(` emisor=${parsed.rfcEmisor} (${parsed.nombreEmisor}, régimen ${parsed.regimenFiscalEmisor})`);
|
||||
console.log(` receptor=${parsed.rfcReceptor} (${parsed.nombreReceptor}, régimen ${parsed.regimenFiscalReceptor})`);
|
||||
console.log(` subtotal=${parsed.subtotal} total=${parsed.total} iva_traslado=${parsed.ivaTraslado}`);
|
||||
|
||||
// Upsert rfcs emisor
|
||||
const { rows: [emisorRow] } = await pool.query(
|
||||
`INSERT INTO rfcs (rfc, razon_social, regimen_fiscal) VALUES ($1, $2, $3)
|
||||
ON CONFLICT (rfc) DO UPDATE SET
|
||||
razon_social = COALESCE(NULLIF($2, ''), rfcs.razon_social),
|
||||
regimen_fiscal = CASE WHEN $3 IS NOT NULL AND $3 != '' THEN $3 ELSE rfcs.regimen_fiscal END
|
||||
RETURNING id`,
|
||||
[parsed.rfcEmisor, parsed.nombreEmisor || null, parsed.regimenFiscalEmisor || null],
|
||||
);
|
||||
// Upsert rfcs receptor
|
||||
const { rows: [receptorRow] } = await pool.query(
|
||||
`INSERT INTO rfcs (rfc, razon_social, regimen_fiscal) VALUES ($1, $2, $3)
|
||||
ON CONFLICT (rfc) DO UPDATE SET
|
||||
razon_social = COALESCE(NULLIF($2, ''), rfcs.razon_social),
|
||||
regimen_fiscal = CASE WHEN $3 IS NOT NULL AND $3 != '' THEN $3 ELSE rfcs.regimen_fiscal END
|
||||
RETURNING id`,
|
||||
[parsed.rfcReceptor, parsed.nombreReceptor || null, parsed.regimenFiscalReceptor || null],
|
||||
);
|
||||
|
||||
await pool.query(
|
||||
`UPDATE cfdis SET
|
||||
fecha_cert_sat = $2,
|
||||
rfc_emisor_id = $3, rfc_emisor = $4, nombre_emisor = $5,
|
||||
regimen_fiscal_emisor = $6,
|
||||
rfc_receptor_id = $7, rfc_receptor = $8, nombre_receptor = $9,
|
||||
regimen_fiscal_receptor = $10,
|
||||
subtotal = $11, subtotal_mxn = $11,
|
||||
total = $12, total_mxn = $12,
|
||||
iva_traslado = $13, iva_traslado_mxn = $13,
|
||||
iva_retencion = $14, iva_retencion_mxn = $14,
|
||||
xml_original = $15,
|
||||
serie = COALESCE($16, serie), folio = COALESCE($17, folio)
|
||||
WHERE id = $1`,
|
||||
[
|
||||
row.id,
|
||||
parsed.fechaCertSat,
|
||||
emisorRow.id, parsed.rfcEmisor, parsed.nombreEmisor,
|
||||
parsed.regimenFiscalEmisor,
|
||||
receptorRow.id, parsed.rfcReceptor, parsed.nombreReceptor,
|
||||
parsed.regimenFiscalReceptor,
|
||||
parsed.subtotal,
|
||||
parsed.total,
|
||||
parsed.ivaTraslado,
|
||||
parsed.ivaRetencion,
|
||||
xmlString,
|
||||
parsed.serie, parsed.folio,
|
||||
],
|
||||
);
|
||||
|
||||
console.log(` ✅ actualizada fila id=${row.id}`);
|
||||
ok++;
|
||||
} catch (e: any) {
|
||||
console.log(` ❌ error: ${e?.message || String(e)}`);
|
||||
fail++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n=== Resumen: ${ok} actualizadas, ${fail} fallidas ===`);
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });
|
||||
174
apps/api/scripts/backfill-fechas-tz.ts
Normal file
174
apps/api/scripts/backfill-fechas-tz.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* Backfill de `fecha_emision` (y opcionalmente `fecha_cert_sat`) para CFDIs
|
||||
* sincronizados antes del fix de zona horaria. El parser convertía la fecha
|
||||
* del XML ("2025-12-31T18:37:51") asumiéndola como hora local de la máquina
|
||||
* y la guardaba en UTC ("2026-01-01T00:37:51Z"), corriendo 6 horas y a veces
|
||||
* sacando el CFDI de su mes/año correcto.
|
||||
*
|
||||
* Re-parsea la fecha literal del XML (atributo `Fecha=""` del Comprobante y
|
||||
* `FechaTimbrado=""` del TimbreFiscalDigital) y lo guarda como UTC-literal
|
||||
* (forzando 'Z' al string del XML).
|
||||
*
|
||||
* Solo aplica a CFDIs con `xml_original IS NOT NULL`. Idempotente.
|
||||
*
|
||||
* Uso:
|
||||
* pnpm --filter @horux/api exec tsx scripts/backfill-fechas-tz.ts # ejecuta
|
||||
* pnpm --filter @horux/api exec tsx scripts/backfill-fechas-tz.ts --dry # reporta
|
||||
*/
|
||||
import { prisma, tenantDb } from '../src/config/database.js';
|
||||
|
||||
const DRY_RUN = process.argv.includes('--dry') || process.argv.includes('--dry-run');
|
||||
|
||||
function parseLiteral(str: string | null | undefined): Date | null {
|
||||
if (!str) return null;
|
||||
const s = String(str).trim();
|
||||
if (!s) return null;
|
||||
const hasTz = /[Zz]|[+-]\d{2}:?\d{2}$/.test(s);
|
||||
return new Date(hasTz ? s : s + 'Z');
|
||||
}
|
||||
|
||||
function extractFechaFromXml(xml: string): string | null {
|
||||
// Atributo Fecha del root <cfdi:Comprobante Fecha="...">
|
||||
const m = xml.match(/<cfdi:Comprobante\b[^>]*\bFecha="([^"]+)"/);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
function extractFechaTimbradoFromXml(xml: string): string | null {
|
||||
const m = xml.match(/<tfd:TimbreFiscalDigital\b[^>]*\bFechaTimbrado="([^"]+)"/);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
interface PerTenantResult {
|
||||
tenantId: string;
|
||||
rfc: string;
|
||||
databaseName: string;
|
||||
scanned: number;
|
||||
updatedFechaEmision: number;
|
||||
updatedFechaCert: number;
|
||||
noChange: number;
|
||||
noXmlMatch: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
async function backfillTenant(tenantId: string, rfc: string, databaseName: string): Promise<PerTenantResult> {
|
||||
const result: PerTenantResult = {
|
||||
tenantId, rfc, databaseName,
|
||||
scanned: 0, updatedFechaEmision: 0, updatedFechaCert: 0, noChange: 0, noXmlMatch: 0,
|
||||
};
|
||||
const pool = await tenantDb.getPool(tenantId, databaseName);
|
||||
|
||||
const { rows } = await pool.query<{
|
||||
id: number;
|
||||
uuid: string;
|
||||
fecha_emision: Date;
|
||||
fecha_cert_sat: Date | null;
|
||||
xml_original: string;
|
||||
}>(
|
||||
`SELECT id, uuid, fecha_emision, fecha_cert_sat, xml_original
|
||||
FROM cfdis
|
||||
WHERE xml_original IS NOT NULL
|
||||
ORDER BY id`,
|
||||
);
|
||||
result.scanned = rows.length;
|
||||
if (rows.length === 0) return result;
|
||||
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
for (const row of rows) {
|
||||
const fechaXml = extractFechaFromXml(row.xml_original);
|
||||
const fechaTimbradoXml = extractFechaTimbradoFromXml(row.xml_original);
|
||||
|
||||
if (!fechaXml) {
|
||||
result.noXmlMatch++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const nuevaFecha = parseLiteral(fechaXml);
|
||||
const nuevaFechaCert = fechaTimbradoXml ? parseLiteral(fechaTimbradoXml) : null;
|
||||
|
||||
if (!nuevaFecha) {
|
||||
result.noXmlMatch++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const fechaEmisionActual = row.fecha_emision?.toISOString();
|
||||
const fechaCertActual = row.fecha_cert_sat?.toISOString();
|
||||
const fechaEmisionNueva = nuevaFecha.toISOString();
|
||||
const fechaCertNueva = nuevaFechaCert?.toISOString();
|
||||
|
||||
let updatedThis = false;
|
||||
if (fechaEmisionActual !== fechaEmisionNueva) {
|
||||
await client.query(
|
||||
`UPDATE cfdis SET fecha_emision = $2 WHERE id = $1`,
|
||||
[row.id, nuevaFecha],
|
||||
);
|
||||
result.updatedFechaEmision++;
|
||||
updatedThis = true;
|
||||
}
|
||||
if (nuevaFechaCert && fechaCertActual !== fechaCertNueva) {
|
||||
await client.query(
|
||||
`UPDATE cfdis SET fecha_cert_sat = $2 WHERE id = $1`,
|
||||
[row.id, nuevaFechaCert],
|
||||
);
|
||||
result.updatedFechaCert++;
|
||||
updatedThis = true;
|
||||
}
|
||||
if (!updatedThis) result.noChange++;
|
||||
}
|
||||
|
||||
if (DRY_RUN) await client.query('ROLLBACK');
|
||||
else await client.query('COMMIT');
|
||||
} catch (err: any) {
|
||||
await client.query('ROLLBACK').catch(() => {});
|
||||
result.error = err?.message || String(err);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`=== Backfill fechas (fecha_emision + fecha_cert_sat) ${DRY_RUN ? '(DRY RUN)' : ''} ===\n`);
|
||||
const tenants = await prisma.tenant.findMany({
|
||||
where: { active: true },
|
||||
select: { id: true, rfc: true, databaseName: true },
|
||||
orderBy: { rfc: 'asc' },
|
||||
});
|
||||
console.log(`Tenants activos: ${tenants.length}\n`);
|
||||
|
||||
const results: PerTenantResult[] = [];
|
||||
for (const t of tenants) {
|
||||
process.stdout.write(`[${t.rfc}] ... `);
|
||||
try {
|
||||
const r = await backfillTenant(t.id, t.rfc, t.databaseName);
|
||||
results.push(r);
|
||||
if (r.error) console.log(`ERROR: ${r.error}`);
|
||||
else if (r.scanned === 0) console.log(`sin XMLs (skip)`);
|
||||
else console.log(
|
||||
`scan=${r.scanned} upd_emision=${r.updatedFechaEmision} upd_cert=${r.updatedFechaCert} ` +
|
||||
`sin_cambio=${r.noChange} sin_match=${r.noXmlMatch}${DRY_RUN ? ' (rolled back)' : ''}`,
|
||||
);
|
||||
} catch (err: any) {
|
||||
console.log(`FATAL: ${err?.message || err}`);
|
||||
}
|
||||
}
|
||||
|
||||
const totalScan = results.reduce((s, r) => s + r.scanned, 0);
|
||||
const totalUpdEm = results.reduce((s, r) => s + r.updatedFechaEmision, 0);
|
||||
const totalUpdCert = results.reduce((s, r) => s + r.updatedFechaCert, 0);
|
||||
const tFail = results.filter(r => r.error).length;
|
||||
|
||||
console.log(`\n=== Resumen ===`);
|
||||
console.log(` Tenants procesados: ${results.length}`);
|
||||
console.log(` CFDIs escaneados: ${totalScan}`);
|
||||
console.log(` fecha_emision actualizada: ${totalUpdEm}`);
|
||||
console.log(` fecha_cert_sat actualizada: ${totalUpdCert}`);
|
||||
if (tFail > 0) console.log(` Tenants con error: ${tFail}`);
|
||||
|
||||
await prisma.$disconnect();
|
||||
process.exit(tFail > 0 ? 1 : 0);
|
||||
}
|
||||
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });
|
||||
101
apps/api/scripts/backfill-metricas.ts
Normal file
101
apps/api/scripts/backfill-metricas.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Backfill de métricas mensuales pre-calculadas (Tanda A hot/cold).
|
||||
*
|
||||
* Itera todos los tenants activos, sus contribuyentes, y popula la tabla
|
||||
* `metricas_mensuales` con los agregados de años pasados (desde el CFDI más
|
||||
* antiguo hasta el año actual - 1). El año actual queda on-the-fly.
|
||||
*
|
||||
* Idempotente: usa upsert — re-correrlo no duplica filas, recalcula valores.
|
||||
*
|
||||
* Uso:
|
||||
* pnpm --filter @horux/api exec tsx scripts/backfill-metricas.ts # ejecuta
|
||||
* pnpm --filter @horux/api exec tsx scripts/backfill-metricas.ts --dry # dry-run
|
||||
*
|
||||
* Opciones via env:
|
||||
* BACKFILL_DESDE_ANIO=2023 # limita el rango inferior
|
||||
* BACKFILL_HASTA_ANIO=2024 # default: año actual - 1
|
||||
* BACKFILL_TENANT=<uuid> # procesa solo un tenant
|
||||
*/
|
||||
import { prisma } from '../src/config/database.js';
|
||||
import { backfillTenant } from '../src/services/metricas-compute.service.js';
|
||||
|
||||
const DRY_RUN = process.argv.includes('--dry') || process.argv.includes('--dry-run');
|
||||
const TENANT_FILTER = process.env.BACKFILL_TENANT || null;
|
||||
const DESDE_ANIO = process.env.BACKFILL_DESDE_ANIO ? parseInt(process.env.BACKFILL_DESDE_ANIO, 10) : undefined;
|
||||
const HASTA_ANIO = process.env.BACKFILL_HASTA_ANIO ? parseInt(process.env.BACKFILL_HASTA_ANIO, 10) : undefined;
|
||||
|
||||
async function main() {
|
||||
console.log(`=== Backfill metricas_mensuales ${DRY_RUN ? '(DRY RUN)' : ''} ===\n`);
|
||||
if (DESDE_ANIO) console.log(`Desde año: ${DESDE_ANIO}`);
|
||||
if (HASTA_ANIO) console.log(`Hasta año: ${HASTA_ANIO}`);
|
||||
if (TENANT_FILTER) console.log(`Tenant filtro: ${TENANT_FILTER}`);
|
||||
console.log();
|
||||
|
||||
const tenants = await prisma.tenant.findMany({
|
||||
where: {
|
||||
active: true,
|
||||
...(TENANT_FILTER ? { id: TENANT_FILTER } : {}),
|
||||
},
|
||||
select: { id: true, rfc: true, nombre: true },
|
||||
orderBy: { rfc: 'asc' },
|
||||
});
|
||||
|
||||
console.log(`Tenants activos: ${tenants.length}\n`);
|
||||
|
||||
let totalContribs = 0;
|
||||
let totalMeses = 0;
|
||||
let totalFilas = 0;
|
||||
let totalErrores = 0;
|
||||
|
||||
for (const t of tenants) {
|
||||
process.stdout.write(`[${t.rfc}] ${t.nombre} ... `);
|
||||
try {
|
||||
const r = await backfillTenant(t.id, {
|
||||
dryRun: DRY_RUN,
|
||||
desdeAnio: DESDE_ANIO,
|
||||
hastaAnio: HASTA_ANIO,
|
||||
});
|
||||
if (r.contribuyentesProcesados === 0) {
|
||||
console.log('sin contribuyentes (skip)');
|
||||
} else {
|
||||
console.log(
|
||||
`${r.contribuyentesProcesados} contribs, ${r.mesesProcesados} meses, ` +
|
||||
`${r.filasEscritas} filas${r.errores.length > 0 ? `, ${r.errores.length} errores` : ''}`,
|
||||
);
|
||||
if (r.errores.length > 0 && r.errores.length <= 5) {
|
||||
for (const e of r.errores) {
|
||||
console.log(` ERR (${e.anio}-${String(e.mes).padStart(2, '0')}): ${e.error}`);
|
||||
}
|
||||
} else if (r.errores.length > 5) {
|
||||
console.log(` (${r.errores.length} errores — los primeros 3):`);
|
||||
for (const e of r.errores.slice(0, 3)) {
|
||||
console.log(` ERR (${e.anio}-${String(e.mes).padStart(2, '0')}): ${e.error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
totalContribs += r.contribuyentesProcesados;
|
||||
totalMeses += r.mesesProcesados;
|
||||
totalFilas += r.filasEscritas;
|
||||
totalErrores += r.errores.length;
|
||||
} catch (err: any) {
|
||||
console.log(`FATAL: ${err?.message || err}`);
|
||||
totalErrores++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n=== Resumen ===`);
|
||||
console.log(` Tenants procesados: ${tenants.length}`);
|
||||
console.log(` Contribuyentes: ${totalContribs}`);
|
||||
console.log(` (Contribuyente, mes): ${totalMeses}`);
|
||||
console.log(` Filas metricas_mensuales: ${totalFilas}${DRY_RUN ? ' (NO escritas)' : ''}`);
|
||||
if (totalErrores > 0) console.log(` Errores: ${totalErrores}`);
|
||||
|
||||
await prisma.$disconnect();
|
||||
process.exit(totalErrores > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
main().catch(async (err) => {
|
||||
console.error('Fatal:', err);
|
||||
await prisma.$disconnect().catch(() => {});
|
||||
process.exit(1);
|
||||
});
|
||||
78
apps/api/scripts/backfill-pago-fields.ts
Normal file
78
apps/api/scripts/backfill-pago-fields.ts
Normal 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); });
|
||||
163
apps/api/scripts/backfill-saldo-pendiente.ts
Normal file
163
apps/api/scripts/backfill-saldo-pendiente.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* Backfill de `saldo_pendiente_mxn` para CFDIs I PPD vigentes. Computa el
|
||||
* saldo con la fórmula centralizada en `utils/saldo.ts` (pagos P + NC no-07
|
||||
* + anticipo aplicado si es I/07) y lo persiste.
|
||||
*
|
||||
* Idempotente: corrido varias veces produce el mismo resultado. Safe para
|
||||
* repetir después de un sync SAT masivo o si se sospecha drift.
|
||||
*
|
||||
* Uso:
|
||||
* pnpm --filter @horux/api exec tsx scripts/backfill-saldo-pendiente.ts # ejecuta
|
||||
* pnpm --filter @horux/api exec tsx scripts/backfill-saldo-pendiente.ts --dry # reporta sin escribir
|
||||
*/
|
||||
import { prisma, tenantDb } from '../src/config/database.js';
|
||||
import { saldoComputadoExpr } from '../src/utils/saldo.js';
|
||||
|
||||
const DRY_RUN = process.argv.includes('--dry') || process.argv.includes('--dry-run');
|
||||
|
||||
interface PerTenantResult {
|
||||
tenantId: string;
|
||||
rfc: string;
|
||||
databaseName: string;
|
||||
iPpdsVigentes: number;
|
||||
actualizadas: number;
|
||||
saldoTotalAntes: number;
|
||||
saldoTotalDespues: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
async function backfillTenant(
|
||||
tenantId: string,
|
||||
rfc: string,
|
||||
databaseName: string,
|
||||
): Promise<PerTenantResult> {
|
||||
const result: PerTenantResult = {
|
||||
tenantId,
|
||||
rfc,
|
||||
databaseName,
|
||||
iPpdsVigentes: 0,
|
||||
actualizadas: 0,
|
||||
saldoTotalAntes: 0,
|
||||
saldoTotalDespues: 0,
|
||||
};
|
||||
|
||||
const pool = await tenantDb.getPool(tenantId, databaseName);
|
||||
|
||||
const { rows: count } = await pool.query<{ n: number; suma: string }>(
|
||||
`SELECT COUNT(*)::int AS n, COALESCE(SUM(COALESCE(saldo_pendiente_mxn, total_mxn)), 0) AS suma
|
||||
FROM cfdis
|
||||
WHERE tipo_comprobante = 'I' AND metodo_pago = 'PPD'
|
||||
AND status NOT IN ('Cancelado', '0')`,
|
||||
);
|
||||
result.iPpdsVigentes = count[0]?.n || 0;
|
||||
result.saldoTotalAntes = Number(count[0]?.suma || 0);
|
||||
if (result.iPpdsVigentes === 0) return result;
|
||||
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
// UPDATE masivo con la fórmula centralizada (misma que hooks y reporte).
|
||||
const expr = saldoComputadoExpr('c');
|
||||
const { rowCount } = await client.query(
|
||||
`UPDATE cfdis c
|
||||
SET saldo_pendiente_mxn = ${expr}
|
||||
WHERE c.tipo_comprobante = 'I'
|
||||
AND c.metodo_pago = 'PPD'
|
||||
AND c.status NOT IN ('Cancelado', '0')`,
|
||||
);
|
||||
result.actualizadas = rowCount ?? 0;
|
||||
|
||||
const { rows: cntDespues } = await client.query<{ suma: string }>(
|
||||
`SELECT COALESCE(SUM(COALESCE(saldo_pendiente_mxn, total_mxn)), 0) AS suma
|
||||
FROM cfdis
|
||||
WHERE tipo_comprobante = 'I' AND metodo_pago = 'PPD'
|
||||
AND status NOT IN ('Cancelado', '0')`,
|
||||
);
|
||||
result.saldoTotalDespues = Number(cntDespues[0]?.suma || 0);
|
||||
|
||||
if (DRY_RUN) {
|
||||
await client.query('ROLLBACK');
|
||||
} else {
|
||||
await client.query('COMMIT');
|
||||
}
|
||||
} catch (err: any) {
|
||||
await client.query('ROLLBACK').catch(() => {});
|
||||
result.error = err?.message || String(err);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function fmt(n: number): string {
|
||||
return n.toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`=== Backfill saldo_pendiente_mxn ${DRY_RUN ? '(DRY RUN — no writes)' : ''} ===\n`);
|
||||
|
||||
const tenants = await prisma.tenant.findMany({
|
||||
where: { active: true },
|
||||
select: { id: true, rfc: true, databaseName: true },
|
||||
orderBy: { rfc: 'asc' },
|
||||
});
|
||||
|
||||
console.log(`Tenants activos: ${tenants.length}\n`);
|
||||
|
||||
const results: PerTenantResult[] = [];
|
||||
for (const t of tenants) {
|
||||
process.stdout.write(`[${t.rfc}] ... `);
|
||||
try {
|
||||
const r = await backfillTenant(t.id, t.rfc, t.databaseName);
|
||||
results.push(r);
|
||||
if (r.error) {
|
||||
console.log(`ERROR: ${r.error}`);
|
||||
} else if (r.iPpdsVigentes === 0) {
|
||||
console.log(`sin I PPD vigentes (skip)`);
|
||||
} else {
|
||||
const delta = r.saldoTotalDespues - r.saldoTotalAntes;
|
||||
console.log(
|
||||
`I_PPD=${r.iPpdsVigentes} upd=${r.actualizadas} ` +
|
||||
`antes=${fmt(r.saldoTotalAntes)} despues=${fmt(r.saldoTotalDespues)} ` +
|
||||
`Δ=${delta >= 0 ? '+' : ''}${fmt(delta)}${DRY_RUN ? ' (rolled back)' : ''}`,
|
||||
);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.log(`FATAL: ${err?.message || err}`);
|
||||
results.push({
|
||||
tenantId: t.id,
|
||||
rfc: t.rfc,
|
||||
databaseName: t.databaseName,
|
||||
iPpdsVigentes: 0,
|
||||
actualizadas: 0,
|
||||
saldoTotalAntes: 0,
|
||||
saldoTotalDespues: 0,
|
||||
error: err?.message || String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const totalI = results.reduce((s, r) => s + r.iPpdsVigentes, 0);
|
||||
const totalAntes = results.reduce((s, r) => s + r.saldoTotalAntes, 0);
|
||||
const totalDespues = results.reduce((s, r) => s + r.saldoTotalDespues, 0);
|
||||
const tenantsFailed = results.filter(r => r.error).length;
|
||||
|
||||
console.log(`\n=== Resumen ===`);
|
||||
console.log(` Tenants procesados: ${results.length}`);
|
||||
console.log(` I PPD vigentes total: ${totalI}`);
|
||||
console.log(` Saldo total antes: ${fmt(totalAntes)}`);
|
||||
console.log(` Saldo total después: ${fmt(totalDespues)}${DRY_RUN ? ' (rolled back)' : ''}`);
|
||||
console.log(` Delta (recuperado): ${fmt(totalAntes - totalDespues)} (saldo que ya no está pendiente)`);
|
||||
if (tenantsFailed > 0) console.log(` Tenants con error: ${tenantsFailed}`);
|
||||
|
||||
await prisma.$disconnect();
|
||||
process.exit(tenantsFailed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
main().catch(async (err) => {
|
||||
console.error('Fatal:', err);
|
||||
await prisma.$disconnect().catch(() => {});
|
||||
process.exit(1);
|
||||
});
|
||||
131
apps/api/scripts/bootstrap-horux360-admin.ts
Normal file
131
apps/api/scripts/bootstrap-horux360-admin.ts
Normal 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());
|
||||
75
apps/api/scripts/breakdown-gastos.ts
Normal file
75
apps/api/scripts/breakdown-gastos.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { prisma, tenantDb } from '../src/config/database.js';
|
||||
|
||||
const yearMonth = '2025-02';
|
||||
const contribuyenteId = 'd745a915-6a23-4818-944b-a7e1e18e536a';
|
||||
const tenantRfc = 'DESPACHO_MO3NI6U8_B9VGG';
|
||||
|
||||
async function main() {
|
||||
const tenant = await prisma.tenant.findFirst({ where: { rfc: tenantRfc }, select: { id: true, databaseName: true } });
|
||||
if (!tenant) return;
|
||||
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
|
||||
|
||||
const [anio, mes] = yearMonth.split('-').map(Number);
|
||||
const lastDay = new Date(anio, mes, 0).getDate();
|
||||
const fi = `${yearMonth}-01`;
|
||||
const ff = `${yearMonth}-${String(lastDay).padStart(2, '0')}`;
|
||||
|
||||
const IMP_TRAS = `COALESCE(iva_traslado_mxn,0) + COALESCE(ieps_traslado_mxn,0) + COALESCE(impuestos_locales_trasladado_mxn,0)`;
|
||||
const IMP_TRAS_PAGO = `COALESCE(iva_traslado_pago_mxn,0) + COALESCE(ieps_traslado_pago_mxn,0)`;
|
||||
const EXCL_MONTO = `COALESCE((SELECT SUM(COALESCE(cc.importe_mxn,0) - COALESCE(cc.descuento_mxn,0)) FROM cfdi_conceptos cc WHERE cc.cfdi_id = cfdis.id AND cc.clave_prod_serv IN ('84121603','93161608','85101501','85121800')), 0)`;
|
||||
|
||||
// Drill desglosado por régimen del receptor
|
||||
const { rows } = await pool.query(
|
||||
`SELECT
|
||||
COALESCE(regimen_fiscal_receptor, 'null') AS regimen_rec,
|
||||
type, tipo_comprobante, metodo_pago,
|
||||
COALESCE(cfdi_tipo_relacion, '') AS tipo_rel,
|
||||
COUNT(*)::int AS n,
|
||||
SUM(total_mxn) AS total_bruto,
|
||||
SUM(COALESCE(total_mxn,0) - (${IMP_TRAS}) - (${EXCL_MONTO})) AS total_neto,
|
||||
SUM(COALESCE(monto_pago_mxn,0) - (${IMP_TRAS_PAGO})) AS pago_neto
|
||||
FROM cfdis
|
||||
WHERE (
|
||||
(type='RECIBIDO' AND tipo_comprobante='I' AND metodo_pago='PUE')
|
||||
OR (type='RECIBIDO' AND tipo_comprobante='P')
|
||||
OR (type='RECIBIDO' AND tipo_comprobante='E' AND metodo_pago='PUE' AND COALESCE(cfdi_tipo_relacion,'')<>'07')
|
||||
)
|
||||
AND status NOT IN ('Cancelado','0')
|
||||
AND ((tipo_comprobante='P' AND fecha_pago_p >= $1::date AND fecha_pago_p < ($2::date + interval '1 day'))
|
||||
OR (tipo_comprobante!='P' AND fecha_emision >= $1::date AND fecha_emision < ($2::date + interval '1 day')))
|
||||
AND contribuyente_id = $3
|
||||
GROUP BY regimen_rec, type, tipo_comprobante, metodo_pago, tipo_rel
|
||||
ORDER BY regimen_rec, tipo_comprobante, metodo_pago`,
|
||||
[fi, ff, contribuyenteId],
|
||||
);
|
||||
|
||||
const byReg: Record<string, { fact: number; pago: number; nc: number; detalle: any[] }> = {};
|
||||
for (const r of rows) {
|
||||
const reg = r.regimen_rec;
|
||||
if (!byReg[reg]) byReg[reg] = { fact: 0, pago: 0, nc: 0, detalle: [] };
|
||||
const v = r.tipo_comprobante === 'P' ? Number(r.pago_neto) : Number(r.total_neto);
|
||||
byReg[reg].detalle.push({ tc: r.tipo_comprobante, mp: r.metodo_pago, rel: r.tipo_rel, n: r.n, valor: v, bruto: Number(r.total_bruto) });
|
||||
if (r.tipo_comprobante === 'I') byReg[reg].fact += v;
|
||||
else if (r.tipo_comprobante === 'P') byReg[reg].pago += v;
|
||||
else if (r.tipo_comprobante === 'E') byReg[reg].nc += v;
|
||||
}
|
||||
|
||||
console.log(`\n=== DRILL-DOWN por régimen del receptor — ${fi} a ${ff} ===\n`);
|
||||
let totalAll = 0;
|
||||
const TODOS_REGS = new Set(['605','606','612','621','625','626','601','603','607','608','610','611','614','615','620','622','623','624']);
|
||||
for (const [reg, v] of Object.entries(byReg).sort()) {
|
||||
const subtot = v.fact + v.pago - v.nc;
|
||||
totalAll += subtot;
|
||||
const inTodos = TODOS_REGS.has(reg) ? '✓' : '✗ (excluido de TODOS_REGIMENES)';
|
||||
console.log(`Régimen ${reg} ${inTodos}`);
|
||||
console.log(` fact=${v.fact.toFixed(2)} pago=${v.pago.toFixed(2)} NC=${v.nc.toFixed(2)} → subtotal=${subtot.toFixed(2)}`);
|
||||
for (const d of v.detalle) {
|
||||
console.log(` ${d.tc} ${d.mp || '-'} rel=${d.rel || '-'} n=${d.n} bruto=${d.bruto.toFixed(2)} neto=${d.valor.toFixed(2)}`);
|
||||
}
|
||||
}
|
||||
console.log(`\nTotal todos regímenes: ${totalAll.toFixed(2)}`);
|
||||
const inTodos = Object.entries(byReg).filter(([r]) => TODOS_REGS.has(r)).reduce((s, [, v]) => s + (v.fact + v.pago - v.nc), 0);
|
||||
console.log(`Total solo en TODOS_REGIMENES: ${inTodos.toFixed(2)}`);
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });
|
||||
67
apps/api/scripts/breakdown-ingresos.ts
Normal file
67
apps/api/scripts/breakdown-ingresos.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Breakdown ingresos por grupo + filas que el drill-down mostraría,
|
||||
* para un contribuyente + mes. Identifica discrepancias entre el
|
||||
* dashboard y el drill.
|
||||
*/
|
||||
import { prisma, tenantDb } from '../src/config/database.js';
|
||||
import { resolveContribuyenteContext } from '../src/utils/contribuyente-context.js';
|
||||
|
||||
const tenantRfc = process.argv[2] || 'DESPACHO_MO3NI6U8_B9VGG';
|
||||
const contribuyenteId = process.argv[3] || 'b3761db6-0b8d-4251-8078-4ddc31e9c75b';
|
||||
const yearMonth = process.argv[4] || '2025-05';
|
||||
|
||||
async function main() {
|
||||
const tenant = await prisma.tenant.findFirst({ where: { rfc: tenantRfc }, select: { id: true, databaseName: true } });
|
||||
if (!tenant) return;
|
||||
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
|
||||
|
||||
const [anio, mes] = yearMonth.split('-').map(Number);
|
||||
const lastDay = new Date(anio, mes, 0).getDate();
|
||||
const fi = `${yearMonth}-01`;
|
||||
const ff = `${yearMonth}-${String(lastDay).padStart(2, '0')}`;
|
||||
|
||||
const ctx = await resolveContribuyenteContext(pool, tenant.id, contribuyenteId);
|
||||
console.log(`\n=== ${yearMonth} ${contribuyenteId} RFC=${ctx.rfc} ===\n`);
|
||||
console.log(`esEmisor: ${ctx.esEmisor}`);
|
||||
console.log(`esReceptor: ${ctx.esReceptor}\n`);
|
||||
|
||||
// Todos los CFDIs donde el contribuyente es emisor en el mes (ingresos potenciales)
|
||||
const { rows: emitidos } = await pool.query(
|
||||
`SELECT uuid, fecha_emision, tipo_comprobante, metodo_pago,
|
||||
cfdi_tipo_relacion, regimen_fiscal_emisor, regimen_fiscal_receptor,
|
||||
total_mxn, monto_pago_mxn
|
||||
FROM cfdis
|
||||
WHERE ${ctx.esEmisor}
|
||||
AND status NOT IN ('Cancelado', '0')
|
||||
AND ((tipo_comprobante='P' AND fecha_pago_p >= $1::date AND fecha_pago_p < ($2::date + interval '1 day'))
|
||||
OR (tipo_comprobante<>'P' AND fecha_emision >= $1::date AND fecha_emision < ($2::date + interval '1 day')))
|
||||
ORDER BY fecha_emision, uuid`,
|
||||
[fi, ff],
|
||||
);
|
||||
|
||||
console.log(`EMITIDOS por el contribuyente en el mes: ${emitidos.length}`);
|
||||
let sumaTotal = 0, sumaPagos = 0;
|
||||
const porRegimen: Record<string, { n: number; total: number; pago: number; types: Record<string, number> }> = {};
|
||||
for (const r of emitidos) {
|
||||
const reg = r.regimen_fiscal_emisor || 'NULL';
|
||||
const tcKey = `${r.tipo_comprobante}${r.metodo_pago ? '/' + r.metodo_pago : ''}${r.cfdi_tipo_relacion ? '/rel=' + r.cfdi_tipo_relacion : ''}`;
|
||||
if (!porRegimen[reg]) porRegimen[reg] = { n: 0, total: 0, pago: 0, types: {} };
|
||||
porRegimen[reg].n++;
|
||||
porRegimen[reg].total += Number(r.total_mxn || 0);
|
||||
porRegimen[reg].pago += Number(r.monto_pago_mxn || 0);
|
||||
porRegimen[reg].types[tcKey] = (porRegimen[reg].types[tcKey] || 0) + 1;
|
||||
sumaTotal += Number(r.total_mxn || 0);
|
||||
sumaPagos += Number(r.monto_pago_mxn || 0);
|
||||
}
|
||||
|
||||
console.log(`Suma total_mxn: ${sumaTotal.toFixed(2)} | Suma monto_pago_mxn: ${sumaPagos.toFixed(2)}\n`);
|
||||
for (const [reg, v] of Object.entries(porRegimen)) {
|
||||
console.log(` Régimen ${reg}: n=${v.n} total=${v.total.toFixed(2)} pago=${v.pago.toFixed(2)}`);
|
||||
for (const [tc, n] of Object.entries(v.types)) {
|
||||
console.log(` ${tc}: ${n}`);
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });
|
||||
24
apps/api/scripts/check-cache-contrib.ts
Normal file
24
apps/api/scripts/check-cache-contrib.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { prisma, tenantDb } from '../src/config/database.js';
|
||||
|
||||
const tenantRfc = process.argv[2] || 'DESPACHO_MO3NI6U8_B9VGG';
|
||||
const contribuyenteId = process.argv[3];
|
||||
const year = process.argv[4] || '2025';
|
||||
const month = process.argv[5];
|
||||
|
||||
async function main() {
|
||||
const tenant = await prisma.tenant.findFirst({ where: { rfc: tenantRfc }, select: { id: true, databaseName: true } });
|
||||
if (!tenant) return;
|
||||
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
|
||||
const monthFilter = month ? `AND mes = ${Number(month)}` : '';
|
||||
const { rows } = await pool.query(
|
||||
`SELECT anio, mes, regimen_fiscal, ingresos_cobrados, egresos_pagados,
|
||||
iva_trasladado_total, iva_acreditable, computed_at
|
||||
FROM metricas_mensuales
|
||||
WHERE contribuyente_id = $1 AND anio = $2 ${monthFilter}
|
||||
ORDER BY mes, regimen_fiscal`,
|
||||
[contribuyenteId, Number(year)],
|
||||
);
|
||||
for (const r of rows) console.log(r);
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });
|
||||
26
apps/api/scripts/check-cache.ts
Normal file
26
apps/api/scripts/check-cache.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { prisma, tenantDb } from '../src/config/database.js';
|
||||
|
||||
async function main() {
|
||||
const tenant = await prisma.tenant.findFirst({ where: { rfc: 'DESPACHO_MO3NI6U8_B9VGG' }, select: { id: true, databaseName: true } });
|
||||
if (!tenant) return;
|
||||
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT anio, mes, regimen_fiscal,
|
||||
ingresos_cobrados, egresos_pagados,
|
||||
iva_trasladado_total, iva_acreditable,
|
||||
computed_at
|
||||
FROM metricas_mensuales
|
||||
WHERE contribuyente_id = $1 AND anio = 2025 AND mes = 2
|
||||
ORDER BY regimen_fiscal`,
|
||||
['d745a915-6a23-4818-944b-a7e1e18e536a'],
|
||||
);
|
||||
console.log(`Cache rows para Feb 2025:`);
|
||||
for (const r of rows) console.log(r);
|
||||
|
||||
// Also force on-the-fly by setting BYPASS
|
||||
process.env.METRICAS_BYPASS_CACHE = '1';
|
||||
console.log(`\n(cache bypassed below is N/A here; the dashboard service reads planCache directly)`);
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });
|
||||
85
apps/api/scripts/check-carlos-emision.ts
Normal file
85
apps/api/scripts/check-carlos-emision.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { prisma, tenantDb } from '../src/config/database.js';
|
||||
|
||||
const RFC_CARLOS = 'TORC9611214CA';
|
||||
|
||||
async function main() {
|
||||
const tenants = await prisma.tenant.findMany({
|
||||
select: { id: true, rfc: true, databaseName: true },
|
||||
});
|
||||
|
||||
let found = false;
|
||||
|
||||
for (const t of tenants) {
|
||||
let pool;
|
||||
try {
|
||||
pool = await tenantDb.getPool(t.id, t.databaseName);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
const { rows: contribs } = await pool.query(
|
||||
`SELECT c.entidad_id, c.rfc, c.regimen_fiscal, e.nombre, fo.facturapi_org_id, fo.csd_uploaded, fo.active AS org_active
|
||||
FROM contribuyentes c
|
||||
JOIN entidades_gestionadas e ON e.id = c.entidad_id
|
||||
LEFT JOIN facturapi_orgs fo ON fo.contribuyente_id = c.entidad_id
|
||||
WHERE UPPER(c.rfc) = $1`,
|
||||
[RFC_CARLOS],
|
||||
);
|
||||
if (contribs.length === 0) continue;
|
||||
|
||||
found = true;
|
||||
console.log(`\n=== Tenant ${t.rfc} — BD ${t.databaseName} ===`);
|
||||
for (const c of contribs) {
|
||||
console.log(`Contribuyente Carlos: ${c.entidad_id}`);
|
||||
console.log(` nombre=${c.nombre}`);
|
||||
console.log(` regimen_fiscal (CSV)=${c.regimen_fiscal}`);
|
||||
console.log(` facturapi_org_id=${c.facturapi_org_id || 'NULL (sin org)'}`);
|
||||
console.log(` csd_uploaded=${c.csd_uploaded} org_active=${c.org_active}`);
|
||||
}
|
||||
|
||||
const { rows: cfdis } = await pool.query(
|
||||
`SELECT uuid, type, tipo_comprobante, metodo_pago, total, total_mxn,
|
||||
rfc_emisor, rfc_receptor, nombre_receptor, status, fecha_emision,
|
||||
source, facturapi_id
|
||||
FROM cfdis
|
||||
WHERE UPPER(rfc_emisor) = $1
|
||||
AND (source = 'facturapi' OR facturapi_id IS NOT NULL OR fecha_emision >= NOW() - interval '2 days')
|
||||
ORDER BY fecha_emision DESC
|
||||
LIMIT 10`,
|
||||
[RFC_CARLOS],
|
||||
);
|
||||
|
||||
console.log(`\nÚltimas ${cfdis.length} facturas (facturapi o recientes) emitidas por ${RFC_CARLOS}:`);
|
||||
for (const c of cfdis) {
|
||||
console.log(` UUID=${c.uuid}`);
|
||||
console.log(` tipo=${c.tipo_comprobante} mp=${c.metodo_pago} status=${c.status} source=${c.source}`);
|
||||
console.log(` receptor=${c.rfc_receptor} (${c.nombre_receptor})`);
|
||||
console.log(` total=${c.total} total_mxn=${c.total_mxn}`);
|
||||
console.log(` fecha_emision=${c.fecha_emision?.toISOString?.() || c.fecha_emision}`);
|
||||
console.log(` facturapi_id=${c.facturapi_id}`);
|
||||
}
|
||||
|
||||
const { rows: [anyEmitido] } = await pool.query(
|
||||
`SELECT COUNT(*)::int AS total,
|
||||
SUM(CASE WHEN source='facturapi' THEN 1 ELSE 0 END)::int AS via_facturapi,
|
||||
SUM(CASE WHEN source='facturapi' AND status NOT IN ('Cancelado','0') THEN 1 ELSE 0 END)::int AS vigentes
|
||||
FROM cfdis
|
||||
WHERE UPPER(rfc_emisor) = $1`,
|
||||
[RFC_CARLOS],
|
||||
);
|
||||
console.log(`\nResumen total CFDIs con rfc_emisor=${RFC_CARLOS}:`);
|
||||
console.log(` total=${anyEmitido.total} via_facturapi=${anyEmitido.via_facturapi} vigentes_facturapi=${anyEmitido.vigentes}`);
|
||||
}
|
||||
|
||||
if (!found) {
|
||||
console.log(`\nNo se encontró contribuyente con RFC ${RFC_CARLOS} en ningún tenant.`);
|
||||
}
|
||||
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
main().catch(async e => {
|
||||
console.error(e);
|
||||
await prisma.$disconnect().catch(() => {});
|
||||
process.exit(1);
|
||||
});
|
||||
72
apps/api/scripts/check-carlos-lco.ts
Normal file
72
apps/api/scripts/check-carlos-lco.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { prisma, tenantDb } from '../src/config/database.js';
|
||||
import { env } from '../src/config/env.js';
|
||||
|
||||
async function main() {
|
||||
const tenant = await prisma.tenant.findFirst({ where: { rfc: 'DESPACHO_MO3NI6U8_B9VGG' }, select: { id: true, databaseName: true } });
|
||||
if (!tenant) return;
|
||||
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
|
||||
|
||||
// 1. Last CSF stored for Carlos (source of truth on what SAT sees)
|
||||
const { rows: csfs } = await pool.query(
|
||||
`SELECT rfc, created_at, datos->'regimenes' AS regimenes, datos->'obligaciones' AS obligaciones,
|
||||
datos->>'estatusPadron' AS estatus, datos->>'fechaInicioOperaciones' AS fecha_inicio,
|
||||
datos->'domicilio' AS domicilio
|
||||
FROM constancias_situacion_fiscal
|
||||
WHERE UPPER(rfc) = 'TORC9611214CA'
|
||||
ORDER BY created_at DESC LIMIT 1`,
|
||||
);
|
||||
console.log(`\n=== CSF más reciente de Carlos ===`);
|
||||
if (csfs.length === 0) {
|
||||
console.log('NO HAY CSF descargada para este RFC. Eso explica el error de LCO si el contribuyente no ha sincronizado con SAT.');
|
||||
} else {
|
||||
const c = csfs[0];
|
||||
console.log(`created_at: ${c.created_at}`);
|
||||
console.log(`estatusPadron: ${c.estatus}`);
|
||||
console.log(`fechaInicioOper: ${c.fecha_inicio}`);
|
||||
console.log(`Regímenes (CSF):`);
|
||||
if (Array.isArray(c.regimenes)) for (const r of c.regimenes) console.log(' ', r);
|
||||
console.log(`Obligaciones (CSF):`);
|
||||
if (Array.isArray(c.obligaciones)) for (const o of c.obligaciones) console.log(' ', o);
|
||||
}
|
||||
|
||||
// 2. Contribuyente data en BD (lo que estamos usando para llenar la org)
|
||||
const { rows: contrib } = await pool.query(
|
||||
`SELECT c.entidad_id, c.rfc, r.razon_social, c.regimen_fiscal, c.codigo_postal, c.domicilio
|
||||
FROM contribuyentes c
|
||||
LEFT JOIN rfcs r ON UPPER(r.rfc) = UPPER(c.rfc)
|
||||
WHERE UPPER(c.rfc) = 'TORC9611214CA'`,
|
||||
);
|
||||
console.log(`\n=== Contribuyente en BD ===`);
|
||||
console.log(contrib[0]);
|
||||
|
||||
// 3. Facturapi org actual (lo que Facturapi está enviando al SAT)
|
||||
const { rows: org } = await pool.query(
|
||||
`SELECT facturapi_org_id FROM facturapi_orgs WHERE contribuyente_id = $1 AND active = true`,
|
||||
[contrib[0]?.entidad_id],
|
||||
);
|
||||
if (org.length > 0 && env.FACTURAPI_USER_KEY) {
|
||||
const res = await fetch(`https://www.facturapi.io/v2/organizations/${org[0].facturapi_org_id}`, {
|
||||
headers: { 'Authorization': `Bearer ${env.FACTURAPI_USER_KEY}` },
|
||||
});
|
||||
if (res.ok) {
|
||||
const o = await res.json() as any;
|
||||
console.log(`\n=== Facturapi Organization ===`);
|
||||
console.log(`orgId: ${o.id}`);
|
||||
console.log(`name: ${o.name}`);
|
||||
console.log(`legal:`);
|
||||
console.log(` legal_name: ${o.legal?.legal_name}`);
|
||||
console.log(` tax_system: ${o.legal?.tax_system}`);
|
||||
console.log(` name: ${o.legal?.name}`);
|
||||
console.log(` address: ${JSON.stringify(o.legal?.address)}`);
|
||||
console.log(`certificate:`);
|
||||
console.log(` has_certificate: ${o.certificate?.has_certificate}`);
|
||||
console.log(` serial_number: ${o.certificate?.serial_number}`);
|
||||
console.log(` valid_until: ${o.certificate?.valid_until}`);
|
||||
} else {
|
||||
console.log(`Facturapi GET failed: ${res.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });
|
||||
112
apps/api/scripts/check-ieps-inflation.ts
Normal file
112
apps/api/scripts/check-ieps-inflation.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Detecta complementos P cuya ieps_traslado_pago_mxn parece inflada
|
||||
* respecto al monto pagado y respecto a la factura referenciada.
|
||||
*
|
||||
* Heurísticas:
|
||||
* 1. IEPS del P > monto_pago × 1.6 (tasa máxima teórica SAT para bebidas
|
||||
* con alto contenido alcohólico; cualquier cosa arriba es sospechoso).
|
||||
* 2. IEPS del P > IEPS de la factura original a la que se refiere
|
||||
* (imposible — un pago parcial no puede transferir más IEPS que el total).
|
||||
* 3. Ratio IEPS / monto_pago vs IEPS_original / total_original, donde la
|
||||
* proporción del P excede la del original por >5pp (señal de error
|
||||
* del proveedor).
|
||||
*/
|
||||
import { prisma, tenantDb } from '../src/config/database.js';
|
||||
|
||||
async function main() {
|
||||
const tenants = await prisma.tenant.findMany({
|
||||
select: { id: true, rfc: true, databaseName: true },
|
||||
});
|
||||
|
||||
for (const t of tenants) {
|
||||
let pool;
|
||||
try {
|
||||
pool = await tenantDb.getPool(t.id, t.databaseName);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(`\n=== Tenant ${t.rfc} (${t.databaseName}) ===`);
|
||||
|
||||
// Heurística 1: IEPS > 160% del monto
|
||||
const { rows: h1 } = await pool.query(`
|
||||
SELECT uuid, rfc_emisor, rfc_receptor, monto_pago_mxn, ieps_traslado_pago_mxn,
|
||||
(ieps_traslado_pago_mxn / NULLIF(monto_pago_mxn, 0))::numeric(10,4) AS ratio
|
||||
FROM cfdis
|
||||
WHERE tipo_comprobante = 'P'
|
||||
AND status NOT IN ('Cancelado', '0')
|
||||
AND COALESCE(ieps_traslado_pago_mxn, 0) > 0
|
||||
AND COALESCE(monto_pago_mxn, 0) > 0
|
||||
AND ieps_traslado_pago_mxn > monto_pago_mxn * 1.6
|
||||
ORDER BY ieps_traslado_pago_mxn DESC
|
||||
LIMIT 10
|
||||
`);
|
||||
console.log(`\n-- H1: IEPS > monto_pago × 1.6 (${h1.length}) --`);
|
||||
for (const r of h1) {
|
||||
console.log(` ${r.uuid.substring(0, 8)} ${r.rfc_emisor}→${r.rfc_receptor} pago=${Number(r.monto_pago_mxn).toFixed(2)} IEPS=${Number(r.ieps_traslado_pago_mxn).toFixed(2)} ratio=${r.ratio}`);
|
||||
}
|
||||
|
||||
// Heurística 2: IEPS del P > IEPS de la factura referenciada (imposible)
|
||||
// uuid_relacionado es pipe-separated; normalizar
|
||||
const { rows: h2 } = await pool.query(`
|
||||
SELECT p.uuid AS p_uuid, p.rfc_emisor, p.monto_pago_mxn, p.ieps_traslado_pago_mxn,
|
||||
i.uuid AS i_uuid, i.total_mxn AS i_total, i.ieps_traslado_mxn AS i_ieps
|
||||
FROM cfdis p
|
||||
JOIN cfdis i
|
||||
ON LOWER(i.uuid) = ANY(string_to_array(LOWER(COALESCE(p.uuid_relacionado, '')), '|'))
|
||||
AND i.status NOT IN ('Cancelado', '0')
|
||||
WHERE p.tipo_comprobante = 'P'
|
||||
AND p.status NOT IN ('Cancelado', '0')
|
||||
AND COALESCE(p.ieps_traslado_pago_mxn, 0) > 0
|
||||
AND COALESCE(p.ieps_traslado_pago_mxn, 0) > COALESCE(i.ieps_traslado_mxn, 0)
|
||||
ORDER BY p.ieps_traslado_pago_mxn DESC
|
||||
LIMIT 10
|
||||
`);
|
||||
console.log(`\n-- H2: IEPS del P > IEPS de la factura referenciada (${h2.length}) --`);
|
||||
for (const r of h2) {
|
||||
const ratio = r.i_ieps > 0 ? Number(r.ieps_traslado_pago_mxn) / Number(r.i_ieps) : 0;
|
||||
console.log(` P=${r.p_uuid.substring(0, 8)} IEPS_P=${Number(r.ieps_traslado_pago_mxn).toFixed(2)} I=${r.i_uuid.substring(0, 8)} IEPS_I=${Number(r.i_ieps || 0).toFixed(2)} ratio=${ratio.toFixed(2)}x`);
|
||||
}
|
||||
|
||||
// Heurística 3: ratio IEPS/pago del P muy distinto del ratio IEPS/total del I
|
||||
const { rows: h3 } = await pool.query(`
|
||||
SELECT p.uuid AS p_uuid, p.monto_pago_mxn, p.ieps_traslado_pago_mxn,
|
||||
i.uuid AS i_uuid, i.total_mxn AS i_total, i.ieps_traslado_mxn AS i_ieps,
|
||||
(p.ieps_traslado_pago_mxn / NULLIF(p.monto_pago_mxn, 0))::numeric(6,4) AS ratio_p,
|
||||
(i.ieps_traslado_mxn / NULLIF(i.total_mxn, 0))::numeric(6,4) AS ratio_i
|
||||
FROM cfdis p
|
||||
JOIN cfdis i
|
||||
ON LOWER(i.uuid) = ANY(string_to_array(LOWER(COALESCE(p.uuid_relacionado, '')), '|'))
|
||||
AND i.status NOT IN ('Cancelado', '0')
|
||||
WHERE p.tipo_comprobante = 'P'
|
||||
AND p.status NOT IN ('Cancelado', '0')
|
||||
AND COALESCE(p.ieps_traslado_pago_mxn, 0) > 0
|
||||
AND COALESCE(i.ieps_traslado_mxn, 0) > 0
|
||||
AND COALESCE(p.monto_pago_mxn, 0) > 0
|
||||
AND COALESCE(i.total_mxn, 0) > 0
|
||||
AND ABS(
|
||||
(p.ieps_traslado_pago_mxn / p.monto_pago_mxn)
|
||||
- (i.ieps_traslado_mxn / i.total_mxn)
|
||||
) > 0.05
|
||||
ORDER BY p.ieps_traslado_pago_mxn DESC
|
||||
LIMIT 10
|
||||
`);
|
||||
console.log(`\n-- H3: ratio_P − ratio_I > 5pp (${h3.length}) --`);
|
||||
for (const r of h3) {
|
||||
console.log(` P=${r.p_uuid.substring(0, 8)} ratio_P=${r.ratio_p} I=${r.i_uuid.substring(0, 8)} ratio_I=${r.ratio_i} delta=${(Number(r.ratio_p) - Number(r.ratio_i)).toFixed(4)}`);
|
||||
}
|
||||
|
||||
// Resumen: total de P con IEPS > 0
|
||||
const { rows: [summary] } = await pool.query(`
|
||||
SELECT COUNT(*) FILTER (WHERE COALESCE(ieps_traslado_pago_mxn, 0) > 0)::int AS p_con_ieps,
|
||||
COUNT(*) FILTER (WHERE tipo_comprobante = 'P')::int AS p_total
|
||||
FROM cfdis
|
||||
WHERE status NOT IN ('Cancelado', '0')
|
||||
`);
|
||||
console.log(`\nResumen: ${summary.p_con_ieps} P con IEPS > 0 (de ${summary.p_total} P totales)`);
|
||||
}
|
||||
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });
|
||||
76
apps/api/scripts/check-recent-facturapi.ts
Normal file
76
apps/api/scripts/check-recent-facturapi.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { prisma, tenantDb } from '../src/config/database.js';
|
||||
|
||||
const TENANT_RFC = 'DESPACHO_MO3NI6U8_B9VGG';
|
||||
|
||||
async function main() {
|
||||
const tenant = await prisma.tenant.findFirst({
|
||||
where: { rfc: TENANT_RFC },
|
||||
select: { id: true, databaseName: true },
|
||||
});
|
||||
if (!tenant) {
|
||||
console.log('Tenant no encontrado');
|
||||
return;
|
||||
}
|
||||
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
|
||||
|
||||
console.log(`\n=== Tenant ${TENANT_RFC} ===\n`);
|
||||
|
||||
// 1) CFDIs emitidos via Facturapi (cualquier emisor) últimos 7 días
|
||||
console.log(`>> CFDIs con source='facturapi' o facturapi_id no nulo, últimos 7 días:`);
|
||||
const { rows: recientes } = await pool.query(
|
||||
`SELECT uuid, rfc_emisor, rfc_receptor, nombre_receptor, tipo_comprobante, metodo_pago,
|
||||
total, total_mxn, status, fecha_emision, source, facturapi_id
|
||||
FROM cfdis
|
||||
WHERE (source = 'facturapi' OR facturapi_id IS NOT NULL)
|
||||
AND fecha_emision >= NOW() - interval '7 days'
|
||||
ORDER BY fecha_emision DESC
|
||||
LIMIT 20`,
|
||||
);
|
||||
if (recientes.length === 0) console.log(' (ninguno)');
|
||||
for (const r of recientes) {
|
||||
const emisor = r.rfc_emisor || '<NULL>';
|
||||
const receptor = r.rfc_receptor || '<NULL>';
|
||||
console.log(` ${r.uuid}`);
|
||||
console.log(` EMISOR=${emisor} RECEPTOR=${receptor} (${r.nombre_receptor})`);
|
||||
console.log(` tipo=${r.tipo_comprobante}/${r.metodo_pago} total=${r.total} status=${r.status} source=${r.source}`);
|
||||
console.log(` fecha_emision=${r.fecha_emision?.toISOString?.() || r.fecha_emision}`);
|
||||
console.log(` facturapi_id=${r.facturapi_id}`);
|
||||
}
|
||||
|
||||
// 2) CFDIs totales en últimas 2 horas (cualquier emisor, cualquier source)
|
||||
console.log(`\n>> CFDIs insertados en últimas 2 horas (cualquier source):`);
|
||||
const { rows: ultimas } = await pool.query(
|
||||
`SELECT uuid, rfc_emisor, rfc_receptor, tipo_comprobante, total,
|
||||
status, fecha_emision, source, facturapi_id
|
||||
FROM cfdis
|
||||
WHERE fecha_emision >= NOW() - interval '2 hours'
|
||||
ORDER BY fecha_emision DESC
|
||||
LIMIT 20`,
|
||||
);
|
||||
if (ultimas.length === 0) console.log(' (ninguno)');
|
||||
for (const r of ultimas) {
|
||||
console.log(` ${r.uuid} | ${r.rfc_emisor} → ${r.rfc_receptor}`);
|
||||
console.log(` tipo=${r.tipo_comprobante} total=${r.total} status=${r.status} source=${r.source}`);
|
||||
console.log(` facturapi_id=${r.facturapi_id || 'null'}`);
|
||||
}
|
||||
|
||||
// 3) Distribución de source en toda la BD
|
||||
console.log(`\n>> Distribución de 'source' en cfdis:`);
|
||||
const { rows: dist } = await pool.query(
|
||||
`SELECT source, COUNT(*)::int AS cnt
|
||||
FROM cfdis
|
||||
GROUP BY source
|
||||
ORDER BY cnt DESC`,
|
||||
);
|
||||
for (const r of dist) {
|
||||
console.log(` source=${r.source || 'NULL'} → ${r.cnt}`);
|
||||
}
|
||||
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
main().catch(async e => {
|
||||
console.error(e);
|
||||
await prisma.$disconnect().catch(() => {});
|
||||
process.exit(1);
|
||||
});
|
||||
36
apps/api/scripts/check-rfc-emisor.ts
Normal file
36
apps/api/scripts/check-rfc-emisor.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { prisma, tenantDb } from '../src/config/database.js';
|
||||
|
||||
async function main() {
|
||||
const tenant = await prisma.tenant.findFirst({
|
||||
where: { rfc: 'DESPACHO_MO3NI6U8_B9VGG' },
|
||||
select: { id: true, databaseName: true },
|
||||
});
|
||||
if (!tenant) return;
|
||||
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT * FROM rfcs WHERE id IN (23709, 1) ORDER BY id`,
|
||||
);
|
||||
for (const r of rows) {
|
||||
console.log(`\nrfcs id=${r.id}:`);
|
||||
for (const k of Object.keys(r).sort()) {
|
||||
console.log(` ${k} = ${r[k]}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Also look at all 4 Facturapi CFDIs' emisor fields
|
||||
const { rows: all4 } = await pool.query(
|
||||
`SELECT uuid, rfc_emisor, nombre_emisor, rfc_emisor_id, regimen_fiscal_emisor,
|
||||
rfc_receptor, nombre_receptor, subtotal, total, xml_original IS NULL AS no_xml
|
||||
FROM cfdis WHERE source='facturapi' ORDER BY fecha_emision DESC`,
|
||||
);
|
||||
console.log(`\n=== Todas las CFDIs source=facturapi (${all4.length}) ===`);
|
||||
for (const r of all4) {
|
||||
console.log(` ${r.uuid} | emisor='${r.rfc_emisor}' (id=${r.rfc_emisor_id}, nombre='${r.nombre_emisor}', regimen=${r.regimen_fiscal_emisor})`);
|
||||
console.log(` receptor='${r.rfc_receptor}' (${r.nombre_receptor}) subtotal=${r.subtotal} total=${r.total} xml_missing=${r.no_xml}`);
|
||||
}
|
||||
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });
|
||||
63
apps/api/scripts/check-saldo.ts
Normal file
63
apps/api/scripts/check-saldo.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { prisma, tenantDb } from '../src/config/database.js';
|
||||
|
||||
const uuid = (process.argv[2] || '5c874749-748f-11f0-96b1-2b9310891836').toLowerCase();
|
||||
|
||||
async function main() {
|
||||
const tenants = await prisma.tenant.findMany({
|
||||
where: { active: true },
|
||||
select: { id: true, rfc: true, databaseName: true },
|
||||
});
|
||||
for (const t of tenants) {
|
||||
const pool = await tenantDb.getPool(t.id, t.databaseName);
|
||||
const { rows } = await pool.query(
|
||||
`SELECT
|
||||
c.uuid, c.total_mxn,
|
||||
COALESCE((
|
||||
SELECT SUM(COALESCE(p.monto_pago_mxn, 0))
|
||||
FROM cfdis p
|
||||
WHERE p.tipo_comprobante = 'P'
|
||||
AND LOWER(COALESCE(p.uuid_relacionado, '')) LIKE '%' || LOWER(c.uuid) || '%'
|
||||
AND p.status NOT IN ('Cancelado', '0')
|
||||
), 0) AS pagos_p,
|
||||
COALESCE((
|
||||
SELECT SUM(COALESCE(e.total_mxn, 0))
|
||||
FROM cfdis e
|
||||
WHERE e.tipo_comprobante = 'E'
|
||||
AND COALESCE(e.cfdi_tipo_relacion, '') <> '07'
|
||||
AND e.cfdis_relacionados IS NOT NULL
|
||||
AND LOWER(c.uuid) = ANY(string_to_array(LOWER(e.cfdis_relacionados), '|'))
|
||||
AND e.status NOT IN ('Cancelado', '0')
|
||||
), 0) AS ncs,
|
||||
CASE WHEN c.cfdi_tipo_relacion = '07' AND c.cfdis_relacionados IS NOT NULL THEN
|
||||
COALESCE((
|
||||
SELECT SUM(COALESCE(a.total_mxn, 0))
|
||||
FROM cfdis a
|
||||
WHERE LOWER(a.uuid) = ANY(string_to_array(LOWER(c.cfdis_relacionados), '|'))
|
||||
AND a.status NOT IN ('Cancelado', '0')
|
||||
), 0) ELSE 0 END AS anticipo_aplicado,
|
||||
(
|
||||
COALESCE(c.total_mxn, 0)
|
||||
- COALESCE((SELECT SUM(COALESCE(p.monto_pago_mxn, 0)) FROM cfdis p
|
||||
WHERE p.tipo_comprobante = 'P'
|
||||
AND LOWER(COALESCE(p.uuid_relacionado, '')) LIKE '%' || LOWER(c.uuid) || '%'
|
||||
AND p.status NOT IN ('Cancelado', '0')), 0)
|
||||
- COALESCE((SELECT SUM(COALESCE(e.total_mxn, 0)) FROM cfdis e
|
||||
WHERE e.tipo_comprobante = 'E' AND COALESCE(e.cfdi_tipo_relacion,'') <> '07'
|
||||
AND e.cfdis_relacionados IS NOT NULL
|
||||
AND LOWER(c.uuid) = ANY(string_to_array(LOWER(e.cfdis_relacionados), '|'))
|
||||
AND e.status NOT IN ('Cancelado','0')), 0)
|
||||
- CASE WHEN c.cfdi_tipo_relacion = '07' AND c.cfdis_relacionados IS NOT NULL THEN
|
||||
COALESCE((SELECT SUM(COALESCE(a.total_mxn,0)) FROM cfdis a
|
||||
WHERE LOWER(a.uuid) = ANY(string_to_array(LOWER(c.cfdis_relacionados),'|'))
|
||||
AND a.status NOT IN ('Cancelado','0')), 0)
|
||||
ELSE 0 END
|
||||
) AS saldo_computado
|
||||
FROM cfdis c WHERE LOWER(c.uuid) = $1`,
|
||||
[uuid],
|
||||
);
|
||||
if (rows.length === 0) continue;
|
||||
console.log(`[${t.rfc}]`, rows[0]);
|
||||
}
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
main().catch(async (e) => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });
|
||||
37
apps/api/scripts/compare-iva-full.ts
Normal file
37
apps/api/scripts/compare-iva-full.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
process.env.METRICAS_BYPASS_CACHE = '1';
|
||||
import { prisma, tenantDb } from '../src/config/database.js';
|
||||
import { calcularIngresosPorRegimen, calcularEgresosPorRegimen } from '../src/services/dashboard.service.js';
|
||||
import { getResumenIva } from '../src/services/impuestos.service.js';
|
||||
|
||||
const tenantRfc = process.argv[2] || 'DESPACHO_MO3NI6U8_B9VGG';
|
||||
const contribuyenteId = process.argv[3] || 'd745a915-6a23-4818-944b-a7e1e18e536a';
|
||||
const año = Number(process.argv[4] || '2025');
|
||||
|
||||
async function main() {
|
||||
const tenant = await prisma.tenant.findFirst({ where: { rfc: tenantRfc }, select: { id: true, databaseName: true } });
|
||||
if (!tenant) return;
|
||||
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
|
||||
|
||||
console.log(`\n=== IVA trasladado/acreditable vs ingresos/gastos — ${año} contrib=${contribuyenteId} ===\n`);
|
||||
console.log('Mes | Ingresos | IVA tras | Ratio | Gastos | IVA acred | Ratio ');
|
||||
|
||||
for (let m = 1; m <= 12; m++) {
|
||||
const lastDay = new Date(año, m, 0).getDate();
|
||||
const mm = String(m).padStart(2, '0');
|
||||
const fi = `${año}-${mm}-01`;
|
||||
const ff = `${año}-${mm}-${String(lastDay).padStart(2, '0')}`;
|
||||
|
||||
const [ing, gas, iva] = await Promise.all([
|
||||
calcularIngresosPorRegimen(pool, tenant.id, fi, ff, undefined, undefined, false, contribuyenteId),
|
||||
calcularEgresosPorRegimen(pool, tenant.id, fi, ff, undefined, undefined, false, contribuyenteId),
|
||||
getResumenIva(pool, fi, ff, tenant.id, false, contribuyenteId),
|
||||
]);
|
||||
const rTras = ing.total > 0 ? (iva.trasladado / ing.total) * 100 : 0;
|
||||
const rAcr = gas.total > 0 ? (iva.acreditable / gas.total) * 100 : 0;
|
||||
const flagT = Math.abs(rTras - 16) > 3 && ing.total > 0 ? '⚠️' : '';
|
||||
const flagA = Math.abs(rAcr - 16) > 3 && gas.total > 0 ? '⚠️' : '';
|
||||
console.log(`${mm} | ${ing.total.toFixed(2).padStart(12)} | ${iva.trasladado.toFixed(2).padStart(13)} | ${rTras.toFixed(1).padStart(5)}%${flagT} | ${gas.total.toFixed(2).padStart(12)} | ${iva.acreditable.toFixed(2).padStart(13)} | ${rAcr.toFixed(1).padStart(5)}%${flagA}`);
|
||||
}
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });
|
||||
36
apps/api/scripts/compare-iva-gastos.ts
Normal file
36
apps/api/scripts/compare-iva-gastos.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
process.env.METRICAS_BYPASS_CACHE = '1';
|
||||
import { prisma, tenantDb } from '../src/config/database.js';
|
||||
import { calcularEgresosPorRegimen } from '../src/services/dashboard.service.js';
|
||||
import { getResumenIva } from '../src/services/impuestos.service.js';
|
||||
|
||||
const tenantRfc = process.argv[2] || 'DESPACHO_MO3NI6U8_B9VGG';
|
||||
const contribuyenteId = process.argv[3] || 'd745a915-6a23-4818-944b-a7e1e18e536a';
|
||||
const año = Number(process.argv[4] || '2025');
|
||||
|
||||
async function main() {
|
||||
const tenant = await prisma.tenant.findFirst({ where: { rfc: tenantRfc }, select: { id: true, databaseName: true } });
|
||||
if (!tenant) return;
|
||||
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
|
||||
|
||||
console.log(`\n=== IVA acreditable vs Gastos por mes — ${año} contrib=${contribuyenteId} ===\n`);
|
||||
console.log('Mes | Gastos | IVA acreditable | Ratio | Esperado (16%) | Diff');
|
||||
|
||||
for (let m = 1; m <= 12; m++) {
|
||||
const lastDay = new Date(año, m, 0).getDate();
|
||||
const mm = String(m).padStart(2, '0');
|
||||
const fi = `${año}-${mm}-01`;
|
||||
const ff = `${año}-${mm}-${String(lastDay).padStart(2, '0')}`;
|
||||
|
||||
const [gastos, iva] = await Promise.all([
|
||||
calcularEgresosPorRegimen(pool, tenant.id, fi, ff, undefined, undefined, false, contribuyenteId),
|
||||
getResumenIva(pool, fi, ff, tenant.id, false, contribuyenteId),
|
||||
]);
|
||||
const ratio = gastos.total > 0 ? (iva.acreditable / gastos.total) * 100 : 0;
|
||||
const esperado = gastos.total * 0.16;
|
||||
const diff = iva.acreditable - esperado;
|
||||
const flag = Math.abs(ratio - 16) > 3 && gastos.total > 0 ? ' ⚠️' : '';
|
||||
console.log(`${mm} | ${gastos.total.toFixed(2).padStart(13)} | ${iva.acreditable.toFixed(2).padStart(15)} | ${ratio.toFixed(2)}% | ${esperado.toFixed(2).padStart(13)} | ${diff.toFixed(2)}${flag}`);
|
||||
}
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });
|
||||
22
apps/api/scripts/count-07-types.ts
Normal file
22
apps/api/scripts/count-07-types.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { prisma, tenantDb } from '../src/config/database.js';
|
||||
|
||||
async function main() {
|
||||
const tenants = await prisma.tenant.findMany({ select: { id: true, rfc: true, databaseName: true } });
|
||||
for (const t of tenants) {
|
||||
let pool;
|
||||
try { pool = await tenantDb.getPool(t.id, t.databaseName); } catch { continue; }
|
||||
console.log(`\n=== ${t.rfc} ===`);
|
||||
const { rows } = await pool.query(`
|
||||
SELECT tipo_comprobante, metodo_pago, COUNT(*)::int AS cnt
|
||||
FROM cfdis
|
||||
WHERE cfdi_tipo_relacion = '07' AND status NOT IN ('Cancelado','0')
|
||||
GROUP BY tipo_comprobante, metodo_pago
|
||||
ORDER BY cnt DESC
|
||||
`);
|
||||
for (const r of rows) {
|
||||
console.log(` ${r.tipo_comprobante}/${r.metodo_pago || 'null'}: ${r.cnt}`);
|
||||
}
|
||||
}
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });
|
||||
27
apps/api/scripts/count-husberto-07.ts
Normal file
27
apps/api/scripts/count-husberto-07.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { prisma, tenantDb } from '../src/config/database.js';
|
||||
|
||||
const RFC = 'TOAH680201RA2';
|
||||
|
||||
async function main() {
|
||||
const tenants = await prisma.tenant.findMany({ select: { id: true, rfc: true, databaseName: true } });
|
||||
for (const t of tenants) {
|
||||
let pool;
|
||||
try { pool = await tenantDb.getPool(t.id, t.databaseName); } catch { continue; }
|
||||
const { rows } = await pool.query(`
|
||||
SELECT tipo_comprobante, metodo_pago, cfdi_tipo_relacion, COUNT(*)::int AS cnt
|
||||
FROM cfdis
|
||||
WHERE (UPPER(rfc_emisor) = $1 OR UPPER(rfc_receptor) = $1)
|
||||
AND status NOT IN ('Cancelado','0')
|
||||
AND cfdi_tipo_relacion IS NOT NULL
|
||||
GROUP BY tipo_comprobante, metodo_pago, cfdi_tipo_relacion
|
||||
ORDER BY cnt DESC`,
|
||||
[RFC]);
|
||||
if (rows.length === 0) continue;
|
||||
console.log(`\n=== ${t.rfc} (${RFC}) ===`);
|
||||
for (const r of rows) {
|
||||
console.log(` ${r.tipo_comprobante}/${r.metodo_pago || '?'}/rel=${r.cfdi_tipo_relacion}: ${r.cnt}`);
|
||||
}
|
||||
}
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });
|
||||
26
apps/api/scripts/create-carlos.ts
Normal file
26
apps/api/scripts/create-carlos.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { prisma } from '../src/config/database.js';
|
||||
import { hashPassword } from '../src/utils/password.js';
|
||||
|
||||
async function main() {
|
||||
const ivan = await prisma.user.findUnique({ where: { email: 'ivan@horuxfin.com' }, include: { tenant: true } });
|
||||
if (!ivan) { console.error('Ivan not found'); process.exit(1); }
|
||||
|
||||
console.log('Tenant:', ivan.tenant.nombre, '(', ivan.tenant.id, ')');
|
||||
|
||||
const existing = await prisma.user.findUnique({ where: { email: 'carlos@horuxfin.com' } });
|
||||
if (existing) { console.log('Carlos already exists:', existing.id); process.exit(0); }
|
||||
|
||||
const hash = await hashPassword('Aasi940812');
|
||||
const carlos = await prisma.user.create({
|
||||
data: {
|
||||
tenantId: ivan.tenantId,
|
||||
email: 'carlos@horuxfin.com',
|
||||
passwordHash: hash,
|
||||
nombre: 'Carlos Horux',
|
||||
role: 'admin',
|
||||
}
|
||||
});
|
||||
console.log('Carlos created:', carlos.id, carlos.email, carlos.role);
|
||||
}
|
||||
|
||||
main().then(() => process.exit(0)).catch(e => { console.error(e); process.exit(1); });
|
||||
103
apps/api/scripts/debug-cfdi-activos.ts
Normal file
103
apps/api/scripts/debug-cfdi-activos.ts
Normal 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); });
|
||||
154
apps/api/scripts/debug-compensacion-cfdi.ts
Normal file
154
apps/api/scripts/debug-compensacion-cfdi.ts
Normal 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); });
|
||||
111
apps/api/scripts/debug-deducciones-husberto.ts
Normal file
111
apps/api/scripts/debug-deducciones-husberto.ts
Normal 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); });
|
||||
71
apps/api/scripts/debug-drill-buckets.ts
Normal file
71
apps/api/scripts/debug-drill-buckets.ts
Normal 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); });
|
||||
169
apps/api/scripts/debug-i07-ppd.ts
Normal file
169
apps/api/scripts/debug-i07-ppd.ts
Normal 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); });
|
||||
88
apps/api/scripts/debug-i07.ts
Normal file
88
apps/api/scripts/debug-i07.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Desglosa cada I/07 recibida de un contribuyente en un rango, mostrando:
|
||||
* - NETO_CUSTOM(I/07)
|
||||
* - UUIDs en cfdis_relacionados
|
||||
* - NETO_CUSTOM de cada relacionada vigente
|
||||
* - Contribución neta de la I/07 al gasto
|
||||
*
|
||||
* Útil para detectar:
|
||||
* - Múltiples I/07 que referencian el mismo anticipo (doble-resta)
|
||||
* - Anticipos fuera del periodo que dominan la compensación
|
||||
* - UUIDs relacionados incorrectos (apuntan a CFDIs enormes no-anticipo)
|
||||
*/
|
||||
import { prisma, tenantDb } from '../src/config/database.js';
|
||||
|
||||
const tenantRfc = process.argv[2] || 'DESPACHO_MO3NI6U8_B9VGG';
|
||||
const contribuyenteId = process.argv[3] || 'd745a915-6a23-4818-944b-a7e1e18e536a';
|
||||
const yearMonth = process.argv[4] || '2025-07';
|
||||
|
||||
async function main() {
|
||||
const tenant = await prisma.tenant.findFirst({ where: { rfc: tenantRfc }, select: { id: true, databaseName: true } });
|
||||
if (!tenant) return;
|
||||
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
|
||||
|
||||
const [anio, mes] = yearMonth.split('-').map(Number);
|
||||
const lastDay = new Date(anio, mes, 0).getDate();
|
||||
const fi = `${yearMonth}-01`;
|
||||
const ff = `${yearMonth}-${String(lastDay).padStart(2, '0')}`;
|
||||
|
||||
const NETO = (a: string) => `(
|
||||
COALESCE(${a}.total_mxn,0) - COALESCE(${a}.iva_traslado_mxn,0) + COALESCE(${a}.iva_retencion_mxn,0)
|
||||
+ COALESCE(${a}.isr_retencion_mxn,0)
|
||||
- COALESCE(${a}.ieps_traslado_mxn,0) + COALESCE(${a}.ieps_retencion_mxn,0)
|
||||
- COALESCE(${a}.impuestos_locales_trasladado_mxn,0) + COALESCE(${a}.impuestos_locales_retenidos_mxn,0)
|
||||
)`;
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT c.uuid, c.fecha_emision, c.total_mxn, c.rfc_emisor, c.cfdis_relacionados,
|
||||
${NETO('c')} AS neto_i07
|
||||
FROM cfdis c
|
||||
WHERE c.type='RECIBIDO' AND c.tipo_comprobante='I' AND c.metodo_pago='PUE'
|
||||
AND c.cfdi_tipo_relacion='07'
|
||||
AND c.status NOT IN ('Cancelado','0')
|
||||
AND c.fecha_emision >= $1::date AND c.fecha_emision < ($2::date + interval '1 day')
|
||||
AND c.contribuyente_id = $3
|
||||
ORDER BY c.fecha_emision`,
|
||||
[fi, ff, contribuyenteId],
|
||||
);
|
||||
|
||||
console.log(`\n=== I/07 RECIBIDAS en ${fi} a ${ff} ===`);
|
||||
console.log(`Total I/07: ${rows.length}`);
|
||||
|
||||
let sumContrib = 0;
|
||||
for (const r of rows) {
|
||||
const relsUuids = (r.cfdis_relacionados || '').split('|').filter(Boolean).map((u: string) => u.toLowerCase());
|
||||
console.log(`\n I/07 ${r.uuid.substring(0,8)} — fecha=${r.fecha_emision.toISOString().slice(0,10)} — emisor=${r.rfc_emisor}`);
|
||||
console.log(` total_mxn: ${Number(r.total_mxn).toFixed(2)}`);
|
||||
console.log(` NETO(I/07): ${Number(r.neto_i07).toFixed(2)}`);
|
||||
console.log(` relacionados (${relsUuids.length}):`);
|
||||
|
||||
let sumRel = 0;
|
||||
if (relsUuids.length > 0) {
|
||||
const { rows: rels } = await pool.query(
|
||||
`SELECT uuid, fecha_emision, total_mxn, tipo_comprobante, metodo_pago, status, ${NETO('a')} AS neto_rel
|
||||
FROM cfdis a
|
||||
WHERE LOWER(a.uuid) = ANY($1::text[])`,
|
||||
[relsUuids],
|
||||
);
|
||||
for (const rel of rels) {
|
||||
const vig = rel.status === 'Vigente' ? '✓' : '✗';
|
||||
console.log(` ${vig} ${rel.uuid.substring(0,8)} ${rel.tipo_comprobante} ${rel.metodo_pago || '-'} fecha=${rel.fecha_emision?.toISOString?.().slice(0,10) || '-'} total=${Number(rel.total_mxn).toFixed(2)} NETO=${Number(rel.neto_rel).toFixed(2)}`);
|
||||
if (rel.status === 'Vigente') sumRel += Number(rel.neto_rel);
|
||||
}
|
||||
const missing = relsUuids.filter((u: string) => !rels.find((x: any) => x.uuid.toLowerCase() === u));
|
||||
if (missing.length > 0) {
|
||||
console.log(` ⚠️ ${missing.length} UUID(s) relacionados NO están en BD:`);
|
||||
for (const m of missing) console.log(` ${m}`);
|
||||
}
|
||||
}
|
||||
const contrib = Number(r.neto_i07) - sumRel;
|
||||
sumContrib += contrib;
|
||||
console.log(` Σ NETO(rel vigentes): ${sumRel.toFixed(2)}`);
|
||||
console.log(` CONTRIB: ${contrib.toFixed(2)} ${contrib < 0 ? '⚠️ NEGATIVA' : ''}`);
|
||||
}
|
||||
|
||||
console.log(`\nSuma total contribuciones I/07: ${sumContrib.toFixed(2)}`);
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });
|
||||
104
apps/api/scripts/debug-ingresos-horux-may-wider.ts
Normal file
104
apps/api/scripts/debug-ingresos-horux-may-wider.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Amplía la inspección: lista TODOS los CFDIs de mayo-2025 donde Horux 360
|
||||
* aparece como emisor o receptor, marcando cuáles entran al bucket ingresos
|
||||
* y cuáles no + por qué.
|
||||
*/
|
||||
import { prisma, tenantDb } from '../src/config/database.js';
|
||||
import { resolveContribuyenteContext } from '../src/utils/contribuyente-context.js';
|
||||
|
||||
const TENANT_RFC = 'DESPACHO_MO3NI6U8_B9VGG';
|
||||
const CONTRIB_ID = 'b3761db6-0b8d-4251-8078-4ddc31e9c75b';
|
||||
const FI = '2025-05-01';
|
||||
const FF = '2025-05-31';
|
||||
|
||||
async function main() {
|
||||
const tenant = await prisma.tenant.findFirst({
|
||||
where: { rfc: TENANT_RFC }, select: { id: true, databaseName: true },
|
||||
});
|
||||
if (!tenant) return;
|
||||
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
|
||||
const ctx = await resolveContribuyenteContext(pool, tenant.id, CONTRIB_ID);
|
||||
|
||||
console.log(`\n=== TODOS los CFDIs de Horux 360 en mayo-2025 (como emisor o receptor) ===\n`);
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT uuid, type, tipo_comprobante, metodo_pago, status,
|
||||
regimen_fiscal_emisor, regimen_fiscal_receptor,
|
||||
rfc_emisor, rfc_receptor, nombre_receptor, nombre_emisor,
|
||||
total_mxn, monto_pago_mxn, cfdi_tipo_relacion, fecha_emision, source
|
||||
FROM cfdis
|
||||
WHERE ((${ctx.esEmisor}) OR (${ctx.esReceptor}))
|
||||
AND fecha_emision >= $1::date
|
||||
AND fecha_emision < ($2::date + interval '1 day')
|
||||
ORDER BY fecha_emision, tipo_comprobante, total_mxn DESC`,
|
||||
[FI, FF],
|
||||
);
|
||||
|
||||
console.log(`Total CFDIs encontrados: ${rows.length}\n`);
|
||||
|
||||
const buckets: Record<string, any[]> = {
|
||||
ingresosG1: [],
|
||||
ingresosG3: [],
|
||||
ingresosSueldos: [],
|
||||
noIncluye_canceladoOinvalido: [],
|
||||
noIncluye_regimenFuera: [],
|
||||
noIncluye_comoReceptor: [],
|
||||
noIncluye_otroMotivo: [],
|
||||
};
|
||||
|
||||
const G1 = ['606', '612', '621', '625', '626'];
|
||||
const G3 = ['601', '603', '607', '608', '610', '611', '614', '615', '620', '622', '623', '624'];
|
||||
|
||||
for (const r of rows) {
|
||||
const cancel = ['Cancelado', '0'].includes(r.status);
|
||||
const esEmisorRow = String(r.rfc_emisor).toUpperCase() === 'HTS240708LJA';
|
||||
const regE = r.regimen_fiscal_emisor;
|
||||
const regR = r.regimen_fiscal_receptor;
|
||||
|
||||
if (cancel) { buckets.noIncluye_canceladoOinvalido.push(r); continue; }
|
||||
|
||||
if (esEmisorRow) {
|
||||
if (G1.includes(regE)) {
|
||||
if ((r.tipo_comprobante === 'I' && r.metodo_pago === 'PUE') ||
|
||||
(r.tipo_comprobante === 'P') ||
|
||||
(r.tipo_comprobante === 'E' && r.metodo_pago === 'PUE')) {
|
||||
buckets.ingresosG1.push(r); continue;
|
||||
}
|
||||
}
|
||||
if (G3.includes(regE)) {
|
||||
if ((r.tipo_comprobante === 'I' && ['PUE', 'PPD'].includes(r.metodo_pago)) ||
|
||||
(r.tipo_comprobante === 'E' && r.metodo_pago === 'PUE')) {
|
||||
buckets.ingresosG3.push(r); continue;
|
||||
}
|
||||
}
|
||||
if (!G1.includes(regE) && !G3.includes(regE)) {
|
||||
buckets.noIncluye_regimenFuera.push({ ...r, reason: `emisor régimen ${regE} fuera de grupo` });
|
||||
continue;
|
||||
}
|
||||
buckets.noIncluye_otroMotivo.push({ ...r, reason: `emisor tipo=${r.tipo_comprobante}/${r.metodo_pago} no matchea` });
|
||||
continue;
|
||||
}
|
||||
|
||||
// No emisor → receptor
|
||||
if (r.tipo_comprobante === 'N' && r.metodo_pago === 'PUE' && regR === '605') {
|
||||
buckets.ingresosSueldos.push(r); continue;
|
||||
}
|
||||
buckets.noIncluye_comoReceptor.push({ ...r, reason: 'es receptor, no cuenta como ingreso (salvo N/605)' });
|
||||
}
|
||||
|
||||
const fmt = (n: any) => Number(n || 0).toFixed(2);
|
||||
|
||||
for (const [name, list] of Object.entries(buckets)) {
|
||||
if (list.length === 0) continue;
|
||||
console.log(`\n--- ${name} (${list.length}) ---`);
|
||||
for (const r of list) {
|
||||
const fe = r.fecha_emision?.toISOString?.()?.slice(0, 10) || r.fecha_emision;
|
||||
const reason = r.reason ? ` | ${r.reason}` : '';
|
||||
console.log(` ${fe} ${r.tipo_comprobante}/${r.metodo_pago || '-'} status=${r.status} regE=${r.regimen_fiscal_emisor} regR=${r.regimen_fiscal_receptor} ${r.rfc_emisor}→${r.rfc_receptor} total=${fmt(r.total_mxn)} mp=${fmt(r.monto_pago_mxn)} ${r.uuid.substring(0,8)}${reason}`);
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });
|
||||
111
apps/api/scripts/debug-ingresos-horux-may.ts
Normal file
111
apps/api/scripts/debug-ingresos-horux-may.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Debug ingresos Horux 360 mayo-2025 post-Método A:
|
||||
* - Llama al KPI (calcularIngresosPorRegimen)
|
||||
* - Lista los CFDIs que entran al drill-down (mismos filtros del controller)
|
||||
* - Suma manualmente para ver dónde está la discrepancia
|
||||
*/
|
||||
process.env.METRICAS_BYPASS_CACHE = '1';
|
||||
import { prisma, tenantDb } from '../src/config/database.js';
|
||||
import { calcularIngresosPorRegimen, GRUPO_PF_EMPRESARIAL, GRUPO_PM_OTROS } from '../src/services/dashboard.service.js';
|
||||
import { resolveContribuyenteContext } from '../src/utils/contribuyente-context.js';
|
||||
|
||||
const TENANT_RFC = 'DESPACHO_MO3NI6U8_B9VGG';
|
||||
const CONTRIB_ID = 'b3761db6-0b8d-4251-8078-4ddc31e9c75b'; // Horux 360
|
||||
const FI = '2025-05-01';
|
||||
const FF = '2025-05-31';
|
||||
|
||||
async function main() {
|
||||
const tenant = await prisma.tenant.findFirst({
|
||||
where: { rfc: TENANT_RFC },
|
||||
select: { id: true, databaseName: true },
|
||||
});
|
||||
if (!tenant) return;
|
||||
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
|
||||
const ctx = await resolveContribuyenteContext(pool, tenant.id, CONTRIB_ID);
|
||||
|
||||
console.log(`\n=== KPI calcularIngresosPorRegimen ===`);
|
||||
const kpi = await calcularIngresosPorRegimen(
|
||||
pool, tenant.id, FI, FF, undefined, undefined, false, CONTRIB_ID,
|
||||
);
|
||||
console.log(`Total KPI: ${kpi.total.toFixed(2)}`);
|
||||
for (const r of kpi.porRegimen) {
|
||||
console.log(` ${r.regimenClave} ${r.regimenDescripcion.substring(0, 40).padEnd(40)} ${r.monto.toFixed(2)}`);
|
||||
}
|
||||
|
||||
// Replica de los filtros del drill-down bucket 'ingresos' (cfdi.controller.ts:163-187)
|
||||
const IMP_TRAS = `COALESCE(iva_traslado_mxn,0) + COALESCE(ieps_traslado_mxn,0) + COALESCE(impuestos_locales_trasladado_mxn,0)`;
|
||||
const IMP_TRAS_PAGO = `COALESCE(iva_traslado_pago_mxn,0) + COALESCE(ieps_traslado_pago_mxn,0)`;
|
||||
const VIGENTE = `status NOT IN ('Cancelado', '0')`;
|
||||
const CLAVES = `('84121603','93161608','85101501','85121800')`;
|
||||
const EXCL_MONTO = `COALESCE((SELECT SUM(COALESCE(cc.importe_mxn,0)-COALESCE(cc.descuento_mxn,0)) FROM cfdi_conceptos cc WHERE cc.cfdi_id = cfdis.id AND cc.clave_prod_serv IN ${CLAVES}),0)`;
|
||||
|
||||
const g1 = GRUPO_PF_EMPRESARIAL.map(r => `'${r}'`).join(',');
|
||||
const g3 = GRUPO_PM_OTROS.map(r => `'${r}'`).join(',');
|
||||
|
||||
const drillSql = `
|
||||
SELECT id, uuid, type, tipo_comprobante, metodo_pago, regimen_fiscal_emisor,
|
||||
regimen_fiscal_receptor, rfc_emisor, rfc_receptor, nombre_receptor,
|
||||
total_mxn, iva_traslado_mxn, ieps_traslado_mxn, impuestos_locales_trasladado_mxn,
|
||||
monto_pago_mxn, iva_traslado_pago_mxn, ieps_traslado_pago_mxn,
|
||||
cfdi_tipo_relacion, fecha_emision, fecha_pago_p, source,
|
||||
-- neto (lo que "contribuye" a ingresos según grupo)
|
||||
CASE
|
||||
WHEN tipo_comprobante='I' THEN (COALESCE(total_mxn,0) - (${IMP_TRAS}) - (${EXCL_MONTO}))
|
||||
WHEN tipo_comprobante='E' THEN -(COALESCE(total_mxn,0) - (${IMP_TRAS}) - (${EXCL_MONTO}))
|
||||
WHEN tipo_comprobante='P' THEN (COALESCE(monto_pago_mxn,0) - (${IMP_TRAS_PAGO}))
|
||||
WHEN tipo_comprobante='N' THEN COALESCE(total_mxn,0)
|
||||
ELSE 0
|
||||
END AS aporte
|
||||
FROM cfdis
|
||||
WHERE ${VIGENTE}
|
||||
AND fecha_emision >= $1::date AND fecha_emision < ($2::date + interval '1 day')
|
||||
AND (
|
||||
(${ctx.esEmisor} AND regimen_fiscal_emisor IN (${g1}) AND (
|
||||
(tipo_comprobante='I' AND metodo_pago='PUE')
|
||||
OR (tipo_comprobante='P')
|
||||
OR (tipo_comprobante='E' AND metodo_pago='PUE')
|
||||
))
|
||||
OR (${ctx.esReceptor} AND tipo_comprobante='N' AND metodo_pago='PUE' AND regimen_fiscal_receptor='605')
|
||||
OR (${ctx.esEmisor} AND regimen_fiscal_emisor IN (${g3}) AND (
|
||||
(tipo_comprobante='I' AND metodo_pago IN ('PUE','PPD'))
|
||||
OR (tipo_comprobante='E' AND metodo_pago='PUE')
|
||||
))
|
||||
)
|
||||
ORDER BY fecha_emision, tipo_comprobante, total_mxn DESC
|
||||
`;
|
||||
|
||||
const { rows } = await pool.query(drillSql, [FI, FF]);
|
||||
console.log(`\n=== Drill-down (${rows.length} CFDIs) ===`);
|
||||
|
||||
let sumDrill = 0;
|
||||
const perRegimen: Record<string, number> = {};
|
||||
|
||||
for (const r of rows) {
|
||||
const aporte = Number(r.aporte || 0);
|
||||
sumDrill += aporte;
|
||||
const reg = r.regimen_fiscal_emisor || r.regimen_fiscal_receptor || '?';
|
||||
perRegimen[reg] = (perRegimen[reg] || 0) + aporte;
|
||||
|
||||
const fe = r.fecha_emision?.toISOString?.()?.slice(0, 10) || r.fecha_emision;
|
||||
const rel07 = r.cfdi_tipo_relacion === '07' ? ' [07]' : '';
|
||||
const src = r.source === 'facturapi' ? ' [facturapi]' : '';
|
||||
console.log(
|
||||
` ${fe} ${r.tipo_comprobante}/${r.metodo_pago}${rel07}${src} ` +
|
||||
`reg=${reg} ${String(r.rfc_emisor).padEnd(14)}→${String(r.rfc_receptor).padEnd(14)} ` +
|
||||
`total=${Number(r.total_mxn || 0).toFixed(2).padStart(10)} ` +
|
||||
`aporte=${aporte.toFixed(2).padStart(10)} ${r.uuid.substring(0,8)}`
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`\n=== Suma de aportes del drill-down: ${sumDrill.toFixed(2)} ===`);
|
||||
console.log(`Por régimen (drill-down):`);
|
||||
for (const [reg, monto] of Object.entries(perRegimen).sort()) {
|
||||
console.log(` ${reg}: ${monto.toFixed(2)}`);
|
||||
}
|
||||
|
||||
console.log(`\n=== Diferencia KPI − drill: ${(kpi.total - sumDrill).toFixed(2)} ===`);
|
||||
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });
|
||||
34
apps/api/scripts/debug-ncs.ts
Normal file
34
apps/api/scripts/debug-ncs.ts
Normal 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); });
|
||||
67
apps/api/scripts/debug-p-mayo.ts
Normal file
67
apps/api/scripts/debug-p-mayo.ts
Normal 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); });
|
||||
82
apps/api/scripts/decrypt-fiel.ts
Normal file
82
apps/api/scripts/decrypt-fiel.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* CLI script to decrypt FIEL credentials from filesystem backup.
|
||||
* Usage: FIEL_ENCRYPTION_KEY=<key> npx tsx scripts/decrypt-fiel.ts <RFC>
|
||||
*
|
||||
* Decrypted files are written to /tmp/horux-fiel-<RFC>/ and auto-deleted after 30 minutes.
|
||||
*/
|
||||
import { readFile, writeFile, mkdir, rm } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import { createDecipheriv, createHash } from 'crypto';
|
||||
|
||||
const FIEL_PATH = process.env.FIEL_STORAGE_PATH || '/var/horux/fiel';
|
||||
const FIEL_KEY = process.env.FIEL_ENCRYPTION_KEY;
|
||||
|
||||
const rfc = process.argv[2];
|
||||
if (!rfc) {
|
||||
console.error('Usage: FIEL_ENCRYPTION_KEY=<key> npx tsx scripts/decrypt-fiel.ts <RFC>');
|
||||
process.exit(1);
|
||||
}
|
||||
if (!FIEL_KEY) {
|
||||
console.error('Error: FIEL_ENCRYPTION_KEY environment variable is required');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function deriveKey(): Buffer {
|
||||
return createHash('sha256').update(FIEL_KEY!).digest();
|
||||
}
|
||||
|
||||
function decryptBuffer(encrypted: Buffer, iv: Buffer, tag: Buffer): Buffer {
|
||||
const key = deriveKey();
|
||||
const decipher = createDecipheriv('aes-256-gcm', key, iv);
|
||||
decipher.setAuthTag(tag);
|
||||
return Buffer.concat([decipher.update(encrypted), decipher.final()]);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const fielDir = join(FIEL_PATH, rfc.toUpperCase());
|
||||
const outputDir = `/tmp/horux-fiel-${rfc.toUpperCase()}`;
|
||||
|
||||
console.log(`Reading encrypted FIEL from: ${fielDir}`);
|
||||
|
||||
// Read encrypted certificate
|
||||
const cerEnc = await readFile(join(fielDir, 'certificate.cer.enc'));
|
||||
const cerIv = await readFile(join(fielDir, 'certificate.cer.iv'));
|
||||
const cerTag = await readFile(join(fielDir, 'certificate.cer.tag'));
|
||||
|
||||
// Read encrypted private key
|
||||
const keyEnc = await readFile(join(fielDir, 'private_key.key.enc'));
|
||||
const keyIv = await readFile(join(fielDir, 'private_key.key.iv'));
|
||||
const keyTag = await readFile(join(fielDir, 'private_key.key.tag'));
|
||||
|
||||
// Read and decrypt metadata
|
||||
const metaEnc = await readFile(join(fielDir, 'metadata.json.enc'));
|
||||
const metaIv = await readFile(join(fielDir, 'metadata.json.iv'));
|
||||
const metaTag = await readFile(join(fielDir, 'metadata.json.tag'));
|
||||
|
||||
// Decrypt all
|
||||
const cerData = decryptBuffer(cerEnc, cerIv, cerTag);
|
||||
const keyData = decryptBuffer(keyEnc, keyIv, keyTag);
|
||||
const metadata = JSON.parse(decryptBuffer(metaEnc, metaIv, metaTag).toString('utf-8'));
|
||||
|
||||
// Write decrypted files
|
||||
await mkdir(outputDir, { recursive: true, mode: 0o700 });
|
||||
await writeFile(join(outputDir, 'certificate.cer'), cerData, { mode: 0o600 });
|
||||
await writeFile(join(outputDir, 'private_key.key'), keyData, { mode: 0o600 });
|
||||
await writeFile(join(outputDir, 'metadata.json'), JSON.stringify(metadata, null, 2), { mode: 0o600 });
|
||||
|
||||
console.log(`\nDecrypted files written to: ${outputDir}`);
|
||||
console.log('Metadata:', metadata);
|
||||
console.log('\nFiles will be auto-deleted in 30 minutes.');
|
||||
|
||||
// Auto-delete after 30 minutes
|
||||
setTimeout(async () => {
|
||||
await rm(outputDir, { recursive: true, force: true });
|
||||
console.log(`Cleaned up ${outputDir}`);
|
||||
process.exit(0);
|
||||
}, 30 * 60 * 1000);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('Failed to decrypt FIEL:', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
101
apps/api/scripts/deep-egresos.ts
Normal file
101
apps/api/scripts/deep-egresos.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Compara paso a paso los 3 componentes del cálculo de egresos 612 en Feb 2025:
|
||||
* 1) Query exacto que usa calcularEgresosPorRegimen (con FECHA_RANGO / FECHA_PAGO_RANGO)
|
||||
* 2) Vs el drill-down usando fecha efectiva por fila
|
||||
* Detalle al CFDI para encontrar discrepancias.
|
||||
*/
|
||||
import { prisma, tenantDb } from '../src/config/database.js';
|
||||
|
||||
async function main() {
|
||||
const tenant = await prisma.tenant.findFirst({ where: { rfc: 'DESPACHO_MO3NI6U8_B9VGG' }, select: { id: true, databaseName: true } });
|
||||
if (!tenant) return;
|
||||
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
|
||||
|
||||
const fi = '2025-02-01';
|
||||
const ff = '2025-02-28';
|
||||
const contrib = 'd745a915-6a23-4818-944b-a7e1e18e536a';
|
||||
const reg = '612';
|
||||
const IMP_TRAS = `COALESCE(iva_traslado_mxn,0) + COALESCE(ieps_traslado_mxn,0) + COALESCE(impuestos_locales_trasladado_mxn,0)`;
|
||||
const IMP_TRAS_PAGO = `COALESCE(iva_traslado_pago_mxn,0) + COALESCE(ieps_traslado_pago_mxn,0)`;
|
||||
const EXCL = `COALESCE((SELECT SUM(COALESCE(cc.importe_mxn,0) - COALESCE(cc.descuento_mxn,0)) FROM cfdi_conceptos cc WHERE cc.cfdi_id = cfdis.id AND cc.clave_prod_serv IN ('84121603','93161608','85101501','85121800')), 0)`;
|
||||
|
||||
// QUERY 1 FACTURAS (idéntico a calcularEgresosPorRegimen)
|
||||
const f = await pool.query(
|
||||
`SELECT uuid, total_mxn, (${IMP_TRAS}) AS imp, (${EXCL}) AS excl,
|
||||
COALESCE(total_mxn,0) - (${IMP_TRAS}) - (${EXCL}) AS neto,
|
||||
cfdi_tipo_relacion AS rel
|
||||
FROM cfdis
|
||||
WHERE type='RECIBIDO' AND tipo_comprobante='I' AND metodo_pago='PUE'
|
||||
AND status NOT IN ('Cancelado','0')
|
||||
AND fecha_emision >= $1::date AND fecha_emision < ($2::date + interval '1 day')
|
||||
AND regimen_fiscal_receptor = $3
|
||||
AND contribuyente_id = $4
|
||||
ORDER BY fecha_emision`,
|
||||
[fi, ff, reg, contrib],
|
||||
);
|
||||
const sumF = f.rows.reduce((s, r) => s + Number(r.neto), 0);
|
||||
console.log(`FACTURAS I PUE reg=${reg}: n=${f.rows.length} sum_neto=${sumF.toFixed(2)}`);
|
||||
|
||||
// QUERY 2 PAGOS P
|
||||
const p = await pool.query(
|
||||
`SELECT uuid, monto_pago_mxn, (${IMP_TRAS_PAGO}) AS imp,
|
||||
COALESCE(monto_pago_mxn,0) - (${IMP_TRAS_PAGO}) AS neto,
|
||||
fecha_pago_p, fecha_emision
|
||||
FROM cfdis
|
||||
WHERE type='RECIBIDO' AND tipo_comprobante='P'
|
||||
AND status NOT IN ('Cancelado','0')
|
||||
AND fecha_pago_p >= $1::date AND fecha_pago_p < ($2::date + interval '1 day')
|
||||
AND regimen_fiscal_receptor = $3
|
||||
AND contribuyente_id = $4
|
||||
ORDER BY fecha_pago_p`,
|
||||
[fi, ff, reg, contrib],
|
||||
);
|
||||
const sumP = p.rows.reduce((s, r) => s + Number(r.neto), 0);
|
||||
console.log(`PAGOS P reg=${reg} (fecha_pago_p): n=${p.rows.length} sum_neto=${sumP.toFixed(2)}`);
|
||||
|
||||
// También probar con fecha_emision del P (alternativo)
|
||||
const pEmis = await pool.query(
|
||||
`SELECT uuid, COALESCE(monto_pago_mxn,0) - (${IMP_TRAS_PAGO}) AS neto,
|
||||
fecha_pago_p, fecha_emision
|
||||
FROM cfdis
|
||||
WHERE type='RECIBIDO' AND tipo_comprobante='P'
|
||||
AND status NOT IN ('Cancelado','0')
|
||||
AND fecha_emision >= $1::date AND fecha_emision < ($2::date + interval '1 day')
|
||||
AND regimen_fiscal_receptor = $3
|
||||
AND contribuyente_id = $4
|
||||
ORDER BY fecha_emision`,
|
||||
[fi, ff, reg, contrib],
|
||||
);
|
||||
const sumPe = pEmis.rows.reduce((s, r) => s + Number(r.neto), 0);
|
||||
console.log(` (alt) PAGOS P filtrados por fecha_emision: n=${pEmis.rows.length} sum_neto=${sumPe.toFixed(2)}`);
|
||||
|
||||
// QUERY 3 NC
|
||||
const n = await pool.query(
|
||||
`SELECT uuid, total_mxn, (${IMP_TRAS}) AS imp, (${EXCL}) AS excl,
|
||||
COALESCE(total_mxn,0) - (${IMP_TRAS}) - (${EXCL}) AS neto,
|
||||
cfdi_tipo_relacion AS rel
|
||||
FROM cfdis
|
||||
WHERE type='RECIBIDO' AND tipo_comprobante='E' AND metodo_pago='PUE'
|
||||
AND COALESCE(cfdi_tipo_relacion,'') <> '07'
|
||||
AND status NOT IN ('Cancelado','0')
|
||||
AND fecha_emision >= $1::date AND fecha_emision < ($2::date + interval '1 day')
|
||||
AND regimen_fiscal_receptor = $3
|
||||
AND contribuyente_id = $4`,
|
||||
[fi, ff, reg, contrib],
|
||||
);
|
||||
const sumN = n.rows.reduce((s, r) => s + Number(r.neto), 0);
|
||||
console.log(`NC E PUE excl 07 reg=${reg}: n=${n.rows.length} sum_neto=${sumN.toFixed(2)}`);
|
||||
|
||||
console.log(`\nTotal ON-THE-FLY (reg 612): ${(sumF + sumP - sumN).toFixed(2)}`);
|
||||
console.log(`Cache dice: 446180.10`);
|
||||
console.log(`Delta: ${((sumF + sumP - sumN) - 446180.10).toFixed(2)}`);
|
||||
|
||||
// Detalle de los P para investigar — fecha_emision vs fecha_pago_p
|
||||
console.log(`\nDetalle PAGOS P (filtrados por fecha_pago_p):`);
|
||||
for (const r of p.rows) {
|
||||
console.log(` ${r.uuid.substring(0,8)} monto=${Number(r.monto_pago_mxn).toFixed(2)} neto=${Number(r.neto).toFixed(2)} fecha_pago_p=${r.fecha_pago_p?.toISOString?.()?.slice(0,10)} fecha_emision=${r.fecha_emision?.toISOString?.()?.slice(0,10)}`);
|
||||
}
|
||||
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });
|
||||
55
apps/api/scripts/detail-ingresos.ts
Normal file
55
apps/api/scripts/detail-ingresos.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/** Detalle neto de cada CFDI del dashboard para Horux 360 mayo 2025. */
|
||||
import { prisma, tenantDb } from '../src/config/database.js';
|
||||
import { resolveContribuyenteContext } from '../src/utils/contribuyente-context.js';
|
||||
|
||||
async function main() {
|
||||
const tenant = await prisma.tenant.findFirst({ where: { rfc: 'DESPACHO_MO3NI6U8_B9VGG' }, select: { id: true, databaseName: true } });
|
||||
if (!tenant) return;
|
||||
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
|
||||
const ctx = await resolveContribuyenteContext(pool, tenant.id, 'b3761db6-0b8d-4251-8078-4ddc31e9c75b');
|
||||
|
||||
// Facturas I PUE (rendición con la misma lógica de g1Facturas)
|
||||
const { rows: fact } = await pool.query(
|
||||
`SELECT uuid, total_mxn,
|
||||
iva_traslado_mxn, ieps_traslado_mxn, impuestos_locales_trasladado_mxn,
|
||||
iva_retencion_mxn, isr_retencion_mxn, ieps_retencion_mxn, impuestos_locales_retenidos_mxn,
|
||||
cfdi_tipo_relacion,
|
||||
(COALESCE(total_mxn,0) - COALESCE(iva_traslado_mxn,0) - COALESCE(ieps_traslado_mxn,0) - COALESCE(impuestos_locales_trasladado_mxn,0)) AS neto_normal
|
||||
FROM cfdis
|
||||
WHERE ${ctx.esEmisor} AND tipo_comprobante='I' AND metodo_pago='PUE'
|
||||
AND status NOT IN ('Cancelado','0')
|
||||
AND fecha_emision >= '2025-05-01'::date AND fecha_emision < '2025-05-31'::date + interval '1 day'
|
||||
AND regimen_fiscal_emisor = '626'
|
||||
ORDER BY fecha_emision`,
|
||||
);
|
||||
console.log(`\nI PUE régimen 626:`);
|
||||
for (const r of fact) {
|
||||
console.log(` ${r.uuid.substring(0,8)} total=${Number(r.total_mxn).toFixed(2)} iva_tras=${Number(r.iva_traslado_mxn).toFixed(2)} iva_ret=${Number(r.iva_retencion_mxn).toFixed(2)} isr_ret=${Number(r.isr_retencion_mxn).toFixed(2)} neto=${Number(r.neto_normal).toFixed(2)} rel=${r.cfdi_tipo_relacion || '-'}`);
|
||||
}
|
||||
const factNeto = fact.reduce((s, r) => s + Number(r.neto_normal), 0);
|
||||
console.log(` Suma neto facturas: ${factNeto.toFixed(2)}`);
|
||||
|
||||
// Pagos P
|
||||
const { rows: pagos } = await pool.query(
|
||||
`SELECT uuid, fecha_pago_p, monto_pago_mxn,
|
||||
iva_traslado_pago_mxn, ieps_traslado_pago_mxn,
|
||||
iva_retencion_pago_mxn, isr_retencion_pago_mxn, ieps_retencion_pago_mxn,
|
||||
(COALESCE(monto_pago_mxn,0) - COALESCE(iva_traslado_pago_mxn,0) - COALESCE(ieps_traslado_pago_mxn,0)) AS neto_normal
|
||||
FROM cfdis
|
||||
WHERE ${ctx.esEmisor} AND tipo_comprobante='P'
|
||||
AND status NOT IN ('Cancelado','0')
|
||||
AND fecha_pago_p >= '2025-05-01'::date AND fecha_pago_p < '2025-05-31'::date + interval '1 day'
|
||||
AND regimen_fiscal_emisor = '626'
|
||||
ORDER BY fecha_pago_p`,
|
||||
);
|
||||
console.log(`\nPagos P régimen 626:`);
|
||||
for (const r of pagos) {
|
||||
console.log(` ${r.uuid.substring(0,8)} monto_pago=${Number(r.monto_pago_mxn).toFixed(2)} iva_tras_pago=${Number(r.iva_traslado_pago_mxn).toFixed(2)} iva_ret_pago=${Number(r.iva_retencion_pago_mxn).toFixed(2)} neto=${Number(r.neto_normal).toFixed(2)}`);
|
||||
}
|
||||
const pagosNeto = pagos.reduce((s, r) => s + Number(r.neto_normal), 0);
|
||||
console.log(` Suma neto pagos: ${pagosNeto.toFixed(2)}`);
|
||||
|
||||
console.log(`\nTOTAL facturas + pagos: ${(factNeto + pagosNeto).toFixed(2)}`);
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });
|
||||
68
apps/api/scripts/detail-iva-mes.ts
Normal file
68
apps/api/scripts/detail-iva-mes.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
/** Breakdown: qué CFDIs contribuyen al IVA acreditable vs al gasto. */
|
||||
import { prisma, tenantDb } from '../src/config/database.js';
|
||||
import { resolveContribuyenteContext } from '../src/utils/contribuyente-context.js';
|
||||
|
||||
const tenantRfc = process.argv[2] || 'DESPACHO_MO3NI6U8_B9VGG';
|
||||
const contribuyenteId = process.argv[3] || 'd745a915-6a23-4818-944b-a7e1e18e536a';
|
||||
const yearMonth = process.argv[4] || '2025-12';
|
||||
|
||||
async function main() {
|
||||
const tenant = await prisma.tenant.findFirst({ where: { rfc: tenantRfc }, select: { id: true, databaseName: true } });
|
||||
if (!tenant) return;
|
||||
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
|
||||
const ctx = await resolveContribuyenteContext(pool, tenant.id, contribuyenteId);
|
||||
|
||||
const [anio, mes] = yearMonth.split('-').map(Number);
|
||||
const lastDay = new Date(anio, mes, 0).getDate();
|
||||
const fi = `${yearMonth}-01`;
|
||||
const ff = `${yearMonth}-${String(lastDay).padStart(2, '0')}`;
|
||||
|
||||
const IMP_TRAS = `COALESCE(iva_traslado_mxn,0) + COALESCE(ieps_traslado_mxn,0) + COALESCE(impuestos_locales_trasladado_mxn,0)`;
|
||||
|
||||
// I PUE recibidas
|
||||
const { rows: facturas } = await pool.query(
|
||||
`SELECT uuid, total_mxn, iva_traslado_mxn, cfdi_tipo_relacion, cfdis_relacionados,
|
||||
(COALESCE(total_mxn,0) - (${IMP_TRAS})) AS neto_normal
|
||||
FROM cfdis
|
||||
WHERE ${ctx.esReceptor} AND tipo_comprobante='I' AND metodo_pago='PUE'
|
||||
AND status NOT IN ('Cancelado','0')
|
||||
AND fecha_emision >= $1::date AND fecha_emision < ($2::date + interval '1 day')
|
||||
ORDER BY total_mxn DESC`,
|
||||
[fi, ff],
|
||||
);
|
||||
|
||||
console.log(`\n=== I PUE recibidas ${yearMonth} ===`);
|
||||
console.log(`# | UUID | total | IVA | neto_normal | rel | cfdis_relacionados`);
|
||||
for (const r of facturas) {
|
||||
const rel = r.cfdi_tipo_relacion || '-';
|
||||
const cr = r.cfdis_relacionados ? ` → ${r.cfdis_relacionados.substring(0,36)}` : '';
|
||||
console.log(` ${r.uuid.substring(0,8)} total=${Number(r.total_mxn).toFixed(2).padStart(12)} IVA=${Number(r.iva_traslado_mxn).toFixed(2).padStart(10)} neto=${Number(r.neto_normal).toFixed(2).padStart(12)} rel=${rel.padEnd(3)}${cr}`);
|
||||
}
|
||||
|
||||
// I PUE recibidas con relación 07 — verificar si el anticipo está en otro mes
|
||||
const i07 = facturas.filter((r: any) => r.cfdi_tipo_relacion === '07');
|
||||
if (i07.length > 0) {
|
||||
console.log(`\nI/07 recibidas en ${yearMonth}: ${i07.length}`);
|
||||
for (const r of i07) {
|
||||
const relsUuids = (r.cfdis_relacionados || '').split('|').filter(Boolean).map((u: string) => u.toLowerCase());
|
||||
if (relsUuids.length > 0) {
|
||||
const { rows: rels } = await pool.query(
|
||||
`SELECT uuid, fecha_emision, total_mxn, iva_traslado_mxn
|
||||
FROM cfdis a
|
||||
WHERE LOWER(a.uuid) = ANY($1::text[])
|
||||
AND a.status NOT IN ('Cancelado','0')`,
|
||||
[relsUuids],
|
||||
);
|
||||
console.log(`\n I/07 ${r.uuid.substring(0,8)} total=${Number(r.total_mxn).toFixed(2)} IVA=${Number(r.iva_traslado_mxn).toFixed(2)}`);
|
||||
for (const a of rels) {
|
||||
const fecha = a.fecha_emision.toISOString().slice(0,10);
|
||||
const fuera = fecha.substring(0,7) !== yearMonth ? ' ← FUERA DEL MES' : '';
|
||||
console.log(` anticipo ${a.uuid.substring(0,8)} fecha=${fecha} total=${Number(a.total_mxn).toFixed(2)} IVA=${Number(a.iva_traslado_mxn).toFixed(2)}${fuera}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });
|
||||
88
apps/api/scripts/drill-ingresos.ts
Normal file
88
apps/api/scripts/drill-ingresos.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Simula el drill-down bucket=ingresos para un contribuyente/mes y muestra
|
||||
* cada CFDI que aparecería en el drill. Permite comparar con el total del
|
||||
* dashboard.
|
||||
*/
|
||||
import { prisma, tenantDb } from '../src/config/database.js';
|
||||
import { resolveContribuyenteContext } from '../src/utils/contribuyente-context.js';
|
||||
|
||||
const tenantRfc = process.argv[2] || 'DESPACHO_MO3NI6U8_B9VGG';
|
||||
const contribuyenteId = process.argv[3] || 'b3761db6-0b8d-4251-8078-4ddc31e9c75b';
|
||||
const yearMonth = process.argv[4] || '2025-05';
|
||||
|
||||
const GRUPO_PF_EMPRESARIAL = ['606', '612', '621', '625', '626'];
|
||||
const GRUPO_PM_OTROS = ['601', '603', '607', '608', '610', '611', '614', '615', '620', '622', '623', '624'];
|
||||
|
||||
async function main() {
|
||||
const tenant = await prisma.tenant.findFirst({ where: { rfc: tenantRfc }, select: { id: true, databaseName: true } });
|
||||
if (!tenant) return;
|
||||
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
|
||||
|
||||
const [anio, mes] = yearMonth.split('-').map(Number);
|
||||
const lastDay = new Date(anio, mes, 0).getDate();
|
||||
const fi = `${yearMonth}-01`;
|
||||
const ff = `${yearMonth}-${String(lastDay).padStart(2, '0')}`;
|
||||
|
||||
const ctx = await resolveContribuyenteContext(pool, tenant.id, contribuyenteId);
|
||||
const esEmisor = ctx.esEmisor;
|
||||
const esReceptor = ctx.esReceptor;
|
||||
const g1 = GRUPO_PF_EMPRESARIAL.map(r => `'${r}'`).join(',');
|
||||
const g3 = GRUPO_PM_OTROS.map(r => `'${r}'`).join(',');
|
||||
|
||||
const FECHA_EFECTIVA = `CASE WHEN tipo_comprobante = 'P' THEN fecha_pago_p ELSE fecha_emision END`;
|
||||
|
||||
// Query idéntico al drill-down bucket=ingresos
|
||||
const { rows } = await pool.query(
|
||||
`SELECT uuid, tipo_comprobante, metodo_pago,
|
||||
regimen_fiscal_emisor, regimen_fiscal_receptor,
|
||||
cfdi_tipo_relacion,
|
||||
total_mxn, monto_pago_mxn,
|
||||
fecha_emision, fecha_pago_p
|
||||
FROM cfdis
|
||||
WHERE 1=1
|
||||
AND (
|
||||
(
|
||||
${esEmisor}
|
||||
AND regimen_fiscal_emisor IN (${g1})
|
||||
AND (
|
||||
(tipo_comprobante = 'I' AND metodo_pago = 'PUE')
|
||||
OR tipo_comprobante = 'P'
|
||||
OR (tipo_comprobante = 'E' AND metodo_pago = 'PUE' AND COALESCE(cfdi_tipo_relacion, '') <> '07')
|
||||
)
|
||||
)
|
||||
OR (
|
||||
${esReceptor}
|
||||
AND tipo_comprobante = 'N' AND metodo_pago = 'PUE'
|
||||
AND regimen_fiscal_receptor = '605'
|
||||
)
|
||||
OR (
|
||||
${esEmisor}
|
||||
AND regimen_fiscal_emisor IN (${g3})
|
||||
AND (
|
||||
(tipo_comprobante = 'I' AND metodo_pago IN ('PUE','PPD'))
|
||||
OR (tipo_comprobante = 'E' AND metodo_pago = 'PUE')
|
||||
)
|
||||
)
|
||||
)
|
||||
AND status NOT IN ('Cancelado','0')
|
||||
AND ${FECHA_EFECTIVA} >= $1::date
|
||||
AND ${FECHA_EFECTIVA} < ($2::date + interval '1 day')
|
||||
ORDER BY ${FECHA_EFECTIVA}`,
|
||||
[fi, ff],
|
||||
);
|
||||
|
||||
console.log(`\n=== Drill bucket=ingresos ${yearMonth} contrib=${ctx.rfc} ===`);
|
||||
console.log(`Filas: ${rows.length}\n`);
|
||||
let sumTotal = 0, sumPago = 0;
|
||||
for (const r of rows) {
|
||||
console.log(` ${r.uuid.substring(0,8)} ${r.tipo_comprobante}${r.metodo_pago ? '/' + r.metodo_pago : ''}${r.cfdi_tipo_relacion ? ' rel=' + r.cfdi_tipo_relacion : ''} reg=${r.regimen_fiscal_emisor || r.regimen_fiscal_receptor} total=${Number(r.total_mxn || 0).toFixed(2)} pago=${Number(r.monto_pago_mxn || 0).toFixed(2)}`);
|
||||
sumTotal += Number(r.total_mxn || 0);
|
||||
sumPago += Number(r.monto_pago_mxn || 0);
|
||||
}
|
||||
console.log(`\nSuma total_mxn (bruto drill): ${sumTotal.toFixed(2)}`);
|
||||
console.log(`Suma monto_pago_mxn: ${sumPago.toFixed(2)}`);
|
||||
console.log(`(Total bruto cuenta I + E a total, y P a monto_pago)`);
|
||||
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });
|
||||
79
apps/api/scripts/extract-terminos.mjs
Normal file
79
apps/api/scripts/extract-terminos.mjs
Normal file
@@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Extrae el texto del PDF de términos y condiciones y lo convierte en un
|
||||
* módulo TypeScript para que el frontend lo renderice sin tener que parsear
|
||||
* el PDF en runtime.
|
||||
*
|
||||
* Además copia el PDF original a `apps/web/public/legal/` para servirlo como
|
||||
* descarga.
|
||||
*
|
||||
* Uso:
|
||||
* pnpm legal:sync
|
||||
*
|
||||
* Cuando se actualiza el documento legal:
|
||||
* 1. Reemplazar `docs/legal/Terminos y condiciones.pdf` por la nueva versión
|
||||
* (mismo nombre de archivo).
|
||||
* 2. Correr `pnpm legal:sync`.
|
||||
* 3. Commit de los cambios (PDF, terminos.ts, PDF copy).
|
||||
*/
|
||||
import { readFileSync, writeFileSync, copyFileSync, mkdirSync } from 'node:fs';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { PDFParse } from 'pdf-parse';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const ROOT = resolve(__dirname, '../../../');
|
||||
const SRC_PDF = resolve(ROOT, 'docs/legal/Terminos y condiciones.pdf');
|
||||
const DEST_PDF = resolve(ROOT, 'apps/web/public/legal/terminos-y-condiciones.pdf');
|
||||
const DEST_TS = resolve(ROOT, 'apps/web/content/terminos.ts');
|
||||
|
||||
async function main() {
|
||||
console.log('[legal:sync] Leyendo:', SRC_PDF);
|
||||
const buf = readFileSync(SRC_PDF);
|
||||
|
||||
const parser = new PDFParse({ data: buf });
|
||||
const textResult = await parser.getText();
|
||||
await parser.destroy();
|
||||
|
||||
const rawText = (textResult.text ?? '').trim();
|
||||
const pages = textResult.total ?? textResult.pages?.length ?? 0;
|
||||
|
||||
if (!rawText) {
|
||||
console.error('[legal:sync] ERROR: el PDF no contiene texto extraíble (¿escaneado sin OCR?).');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Copia el PDF a public/ para que sea descargable
|
||||
mkdirSync(dirname(DEST_PDF), { recursive: true });
|
||||
copyFileSync(SRC_PDF, DEST_PDF);
|
||||
|
||||
// Escribe el texto como módulo TypeScript. Escapa backticks para que el
|
||||
// template literal no rompa si el PDF los contiene.
|
||||
const escaped = rawText.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$\{/g, '\\${');
|
||||
const extractedAt = new Date().toISOString();
|
||||
const content = `// AUTO-GENERADO por \`pnpm legal:sync\`. NO editar a mano.
|
||||
// Fuente: docs/legal/Terminos y condiciones.pdf
|
||||
// Regenerar tras actualizar el PDF.
|
||||
|
||||
export const TERMINOS_TEXT = \`${escaped}\`;
|
||||
|
||||
export const TERMINOS_META = {
|
||||
extractedAt: '${extractedAt}',
|
||||
pages: ${pages},
|
||||
chars: ${rawText.length},
|
||||
} as const;
|
||||
`;
|
||||
|
||||
mkdirSync(dirname(DEST_TS), { recursive: true });
|
||||
writeFileSync(DEST_TS, content, 'utf8');
|
||||
|
||||
console.log(`[legal:sync] OK: ${rawText.length} chars extraídos, ${pages} páginas.`);
|
||||
console.log(`[legal:sync] → ${DEST_PDF}`);
|
||||
console.log(`[legal:sync] → ${DEST_TS}`);
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('[legal:sync] FAIL:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
28
apps/api/scripts/find-contribuyente.ts
Normal file
28
apps/api/scripts/find-contribuyente.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { prisma, tenantDb } from '../src/config/database.js';
|
||||
|
||||
const term = (process.argv[2] || '').toLowerCase();
|
||||
if (!term) { console.error('Usage: tsx scripts/find-contribuyente.ts <texto>'); process.exit(1); }
|
||||
|
||||
async function main() {
|
||||
const tenants = await prisma.tenant.findMany({
|
||||
where: { active: true },
|
||||
select: { id: true, rfc: true, databaseName: true },
|
||||
});
|
||||
for (const t of tenants) {
|
||||
const pool = await tenantDb.getPool(t.id, t.databaseName);
|
||||
const { rows: cols } = await pool.query(
|
||||
`SELECT column_name FROM information_schema.columns WHERE table_name='contribuyentes'`,
|
||||
);
|
||||
const colNames = cols.map((c: any) => c.column_name);
|
||||
const nameCols = colNames.filter(n => n.includes('nombre') || n.includes('razon'));
|
||||
const filterSql = nameCols.map(c => `LOWER(${c}) LIKE '%${term}%'`).join(' OR ');
|
||||
if (!filterSql) continue;
|
||||
const { rows } = await pool.query(`SELECT entidad_id, rfc, ${nameCols.join(',')} FROM contribuyentes WHERE ${filterSql}`);
|
||||
if (rows.length > 0) {
|
||||
console.log(`\n[${t.rfc}]`);
|
||||
for (const r of rows) console.log(r);
|
||||
}
|
||||
}
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });
|
||||
75
apps/api/scripts/find-i07-ppd-cases.ts
Normal file
75
apps/api/scripts/find-i07-ppd-cases.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Encuentra E que referencien directamente a una I/07 PPD vía
|
||||
* `cfdis_relacionados`. Patrón real observado: la E "ajusta" la I/07 PPD,
|
||||
* no al anticipo original. La I/07 PPD apunta al anticipo, la E apunta a
|
||||
* la I/07 PPD.
|
||||
*/
|
||||
import { prisma, tenantDb } from '../src/config/database.js';
|
||||
|
||||
const TARGET_RFC = process.argv[2];
|
||||
|
||||
async function main() {
|
||||
const tenants = await prisma.tenant.findMany({ select: { id: true, rfc: true, databaseName: true } });
|
||||
|
||||
for (const t of tenants) {
|
||||
let pool;
|
||||
try { pool = await tenantDb.getPool(t.id, t.databaseName); } catch { continue; }
|
||||
console.log(`\n=== ${t.rfc}${TARGET_RFC ? ` (RFC=${TARGET_RFC})` : ''} ===`);
|
||||
|
||||
const rfcFilter = TARGET_RFC
|
||||
? `AND (UPPER(i.rfc_emisor) = UPPER('${TARGET_RFC}') OR UPPER(i.rfc_receptor) = UPPER('${TARGET_RFC}'))`
|
||||
: '';
|
||||
|
||||
const { rows } = await pool.query(`
|
||||
SELECT
|
||||
i.uuid AS i_uuid, i.fecha_emision AS i_fecha, i.total_mxn AS i_total,
|
||||
i.iva_traslado_mxn AS i_iva, i.rfc_emisor AS i_emisor, i.rfc_receptor AS i_receptor,
|
||||
i.type AS i_type,
|
||||
e.uuid AS e_uuid, e.cfdi_tipo_relacion AS e_rel, e.metodo_pago AS e_mp,
|
||||
e.fecha_emision AS e_fecha, e.total_mxn AS e_total, e.iva_traslado_mxn AS e_iva,
|
||||
ABS(EXTRACT(EPOCH FROM (e.fecha_emision - i.fecha_emision)) / 86400)::int AS diff_dias,
|
||||
EXTRACT(YEAR FROM i.fecha_emision)::int * 12 + EXTRACT(MONTH FROM i.fecha_emision)::int AS i_periodo,
|
||||
EXTRACT(YEAR FROM e.fecha_emision)::int * 12 + EXTRACT(MONTH FROM e.fecha_emision)::int AS e_periodo
|
||||
FROM cfdis i
|
||||
JOIN cfdis e
|
||||
ON LOWER(i.uuid) = ANY(string_to_array(LOWER(e.cfdis_relacionados), '|'))
|
||||
WHERE i.cfdi_tipo_relacion = '07'
|
||||
AND i.tipo_comprobante = 'I' AND i.metodo_pago = 'PPD'
|
||||
AND i.status NOT IN ('Cancelado','0')
|
||||
AND e.tipo_comprobante = 'E'
|
||||
AND e.status NOT IN ('Cancelado','0')
|
||||
${rfcFilter}
|
||||
ORDER BY i.fecha_emision DESC
|
||||
`);
|
||||
|
||||
console.log(`Total pares: ${rows.length}`);
|
||||
|
||||
const buckets = { mismoMes: 0, eDespues1: 0, eDespuesMas: 0, eAntes: 0 };
|
||||
for (const r of rows) {
|
||||
const diff = Number(r.e_periodo) - Number(r.i_periodo);
|
||||
if (diff < 0) buckets.eAntes++;
|
||||
else if (diff === 0) buckets.mismoMes++;
|
||||
else if (diff === 1) buckets.eDespues1++;
|
||||
else buckets.eDespuesMas++;
|
||||
}
|
||||
console.log(` Mismo mes: ${buckets.mismoMes}`);
|
||||
console.log(` E 1 mes después: ${buckets.eDespues1}`);
|
||||
console.log(` E ≥2 meses después: ${buckets.eDespuesMas}`);
|
||||
console.log(` E antes: ${buckets.eAntes}`);
|
||||
|
||||
if (rows.length > 0) {
|
||||
console.log(`\n Detalle (top ${Math.min(rows.length, 10)}):`);
|
||||
for (const r of rows.slice(0, 10)) {
|
||||
const fi = new Date(r.i_fecha).toISOString().slice(0, 10);
|
||||
const fe = new Date(r.e_fecha).toISOString().slice(0, 10);
|
||||
const i_base = Number(r.i_total) - Number(r.i_iva || 0);
|
||||
const e_base = Number(r.e_total) - Number(r.e_iva || 0);
|
||||
const diff = Number(r.e_periodo) - Number(r.i_periodo);
|
||||
console.log(` I/07 PPD ${r.i_uuid.substring(0,8)} ${fi} base=${i_base.toFixed(2)} ${r.i_emisor}→${r.i_receptor} (${r.i_type})`);
|
||||
console.log(` E/${r.e_rel ?? 'null'}/${r.e_mp || '?'} ${r.e_uuid.substring(0,8)} ${fe} base=${e_base.toFixed(2)} diffMeses=${diff} (${r.diff_dias}d)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });
|
||||
12
apps/api/scripts/find-uuid.ts
Normal file
12
apps/api/scripts/find-uuid.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { prisma, tenantDb } from '../src/config/database.js';
|
||||
const prefix = process.argv[2];
|
||||
async function main() {
|
||||
const ts = await prisma.tenant.findMany({ where: { active: true }, select: { id: true, rfc: true, databaseName: true } });
|
||||
for (const t of ts) {
|
||||
const pool = await tenantDb.getPool(t.id, t.databaseName);
|
||||
const { rows } = await pool.query(`SELECT uuid FROM cfdis WHERE uuid LIKE $1 || '%'`, [prefix]);
|
||||
for (const r of rows) console.log(t.rfc, r.uuid);
|
||||
}
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });
|
||||
104
apps/api/scripts/import-lista-negra.ts
Normal file
104
apps/api/scripts/import-lista-negra.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { readFileSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
const SITUACIONES_VALIDAS = ['Definitivo', 'Presunto', 'Desvirtuado', 'Sentencia Favorable'];
|
||||
|
||||
function parseCsvLine(line: string): string[] {
|
||||
const fields: string[] = [];
|
||||
let current = '';
|
||||
let inQuotes = false;
|
||||
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const c = line[i];
|
||||
if (c === '"') {
|
||||
if (inQuotes && line[i + 1] === '"') {
|
||||
current += '"';
|
||||
i++;
|
||||
} else {
|
||||
inQuotes = !inQuotes;
|
||||
}
|
||||
} else if (c === ',' && !inQuotes) {
|
||||
fields.push(current.trim());
|
||||
current = '';
|
||||
} else {
|
||||
current += c;
|
||||
}
|
||||
}
|
||||
fields.push(current.trim());
|
||||
return fields;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const filePath = resolve(__dirname, '..', '..', '..', 'lista_negra', 'Listado_completo_69-B.csv');
|
||||
console.log('📂 Leyendo:', filePath);
|
||||
|
||||
const data = readFileSync(filePath, 'latin1');
|
||||
const lines = data.split('\n');
|
||||
console.log(`📄 ${lines.length} líneas en el archivo`);
|
||||
|
||||
// Parsear registros (saltar headers: líneas 0, 1, 2)
|
||||
const registros: { rfc: string; nombre: string; situacion: string }[] = [];
|
||||
|
||||
for (let i = 3; i < lines.length; i++) {
|
||||
const line = lines[i].replace(/\r/g, '').trim();
|
||||
if (!line) continue;
|
||||
|
||||
const fields = parseCsvLine(line);
|
||||
if (fields.length < 4) continue;
|
||||
|
||||
const rfc = fields[1]?.trim();
|
||||
const nombre = fields[2]?.trim();
|
||||
const situacion = fields[3]?.trim();
|
||||
|
||||
if (!rfc || !rfc.match(/^[A-Z0-9&]{10,13}$/)) continue;
|
||||
if (!SITUACIONES_VALIDAS.includes(situacion)) continue;
|
||||
|
||||
registros.push({ rfc, nombre, situacion });
|
||||
}
|
||||
|
||||
console.log(`✅ ${registros.length} registros válidos parseados`);
|
||||
|
||||
// Contar por situación
|
||||
const counts: Record<string, number> = {};
|
||||
for (const r of registros) {
|
||||
counts[r.situacion] = (counts[r.situacion] || 0) + 1;
|
||||
}
|
||||
console.log(' Situaciones:', counts);
|
||||
|
||||
// Sincronizar: limpiar y reinsertar todo
|
||||
console.log('🔄 Sincronizando con base de datos...');
|
||||
|
||||
await prisma.listaNegra.deleteMany();
|
||||
|
||||
// Insertar en batches de 500
|
||||
const BATCH = 500;
|
||||
let inserted = 0;
|
||||
|
||||
for (let i = 0; i < registros.length; i += BATCH) {
|
||||
const batch = registros.slice(i, i + BATCH);
|
||||
|
||||
// Deduplicar por RFC (quedarse con el último)
|
||||
const unique = new Map<string, typeof batch[0]>();
|
||||
for (const r of batch) unique.set(r.rfc, r);
|
||||
|
||||
await prisma.listaNegra.createMany({
|
||||
data: Array.from(unique.values()),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
|
||||
inserted += unique.size;
|
||||
if ((i + BATCH) % 5000 === 0 || i + BATCH >= registros.length) {
|
||||
console.log(` ${Math.min(i + BATCH, registros.length)}/${registros.length}...`);
|
||||
}
|
||||
}
|
||||
|
||||
const total = await prisma.listaNegra.count();
|
||||
console.log(`\n🎉 Lista negra actualizada: ${total} registros en la base de datos`);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch(console.error)
|
||||
.finally(() => prisma.$disconnect());
|
||||
26
apps/api/scripts/inspect-cfdi-full.ts
Normal file
26
apps/api/scripts/inspect-cfdi-full.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { prisma, tenantDb } from '../src/config/database.js';
|
||||
|
||||
const rawUuid = process.argv[2];
|
||||
if (!rawUuid) { console.error('Usage: tsx scripts/inspect-cfdi-full.ts <uuid>'); process.exit(1); }
|
||||
const uuid = rawUuid.toLowerCase();
|
||||
|
||||
async function main() {
|
||||
const tenants = await prisma.tenant.findMany({
|
||||
where: { active: true },
|
||||
select: { id: true, rfc: true, databaseName: true },
|
||||
});
|
||||
|
||||
for (const t of tenants) {
|
||||
const pool = await tenantDb.getPool(t.id, t.databaseName);
|
||||
const { rows } = await pool.query(
|
||||
`SELECT * FROM cfdis WHERE LOWER(uuid) = $1`,
|
||||
[uuid],
|
||||
);
|
||||
if (rows.length === 0) continue;
|
||||
console.log(`\n[${t.rfc}] CFDI:`);
|
||||
console.log(rows[0]);
|
||||
}
|
||||
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });
|
||||
90
apps/api/scripts/inspect-cfdi.ts
Normal file
90
apps/api/scripts/inspect-cfdi.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Inspecciona el estado de un CFDI y sus relacionados (pagos + E/07) en todos
|
||||
* los tenants. Útil para debug de saldos pendientes.
|
||||
*
|
||||
* Uso:
|
||||
* pnpm --filter @horux/api exec tsx scripts/inspect-cfdi.ts <uuid>
|
||||
*/
|
||||
import { prisma, tenantDb } from '../src/config/database.js';
|
||||
|
||||
const rawUuid = process.argv[2];
|
||||
if (!rawUuid) {
|
||||
console.error('Usage: tsx scripts/inspect-cfdi.ts <uuid>');
|
||||
process.exit(1);
|
||||
}
|
||||
const uuid = rawUuid.toLowerCase();
|
||||
|
||||
async function main() {
|
||||
const tenants = await prisma.tenant.findMany({
|
||||
where: { active: true },
|
||||
select: { id: true, rfc: true, databaseName: true },
|
||||
orderBy: { rfc: 'asc' },
|
||||
});
|
||||
|
||||
for (const t of tenants) {
|
||||
const pool = await tenantDb.getPool(t.id, t.databaseName);
|
||||
|
||||
const { rows: base } = await pool.query(
|
||||
`SELECT id, uuid, type, tipo_comprobante, metodo_pago, status, fecha_emision,
|
||||
total, total_mxn, monto_pago, monto_pago_mxn,
|
||||
saldo_insoluto, saldo_pendiente, saldo_pendiente_mxn,
|
||||
uuid_relacionado, cfdi_tipo_relacion, cfdis_relacionados,
|
||||
rfc_emisor, rfc_receptor, conciliado, id_conciliacion,
|
||||
source, facturapi_id
|
||||
FROM cfdis WHERE LOWER(uuid) = $1`,
|
||||
[uuid],
|
||||
);
|
||||
|
||||
if (base.length === 0) continue;
|
||||
|
||||
console.log(`\n=== Tenant ${t.rfc} (${t.databaseName}) ===`);
|
||||
console.log('CFDI base:');
|
||||
console.log(base[0]);
|
||||
|
||||
// P complements que apuntan a este UUID via uuid_relacionado (DoctoRelacionado)
|
||||
const { rows: pagosP } = await pool.query(
|
||||
`SELECT id, uuid, type, tipo_comprobante, fecha_emision, fecha_pago_p,
|
||||
monto_pago, monto_pago_mxn, num_parcialidad,
|
||||
uuid_relacionado, status
|
||||
FROM cfdis
|
||||
WHERE tipo_comprobante = 'P' AND LOWER(uuid_relacionado) = $1
|
||||
ORDER BY fecha_pago_p NULLS LAST, id`,
|
||||
[uuid],
|
||||
);
|
||||
console.log(`\nComplementos P que referencian este UUID (DoctoRelacionado): ${pagosP.length}`);
|
||||
for (const r of pagosP) console.log(' ', r);
|
||||
|
||||
// E CFDIs con cfdis_relacionados que contengan este UUID (TipoRelacion=07 típicamente)
|
||||
const { rows: ecfdis } = await pool.query(
|
||||
`SELECT id, uuid, type, tipo_comprobante, metodo_pago, fecha_emision,
|
||||
total, total_mxn, cfdi_tipo_relacion, cfdis_relacionados,
|
||||
status
|
||||
FROM cfdis
|
||||
WHERE tipo_comprobante = 'E'
|
||||
AND cfdis_relacionados IS NOT NULL
|
||||
AND LOWER(cfdis_relacionados) LIKE $1
|
||||
ORDER BY fecha_emision, id`,
|
||||
[`%${uuid}%`],
|
||||
);
|
||||
console.log(`\nCFDIs tipo E con este UUID en cfdis_relacionados: ${ecfdis.length}`);
|
||||
for (const r of ecfdis) console.log(' ', r);
|
||||
|
||||
// Si el base está conciliado, traer la fila
|
||||
if (base[0].id_conciliacion) {
|
||||
const { rows: conc } = await pool.query(
|
||||
`SELECT * FROM conciliaciones WHERE id = $1`,
|
||||
[base[0].id_conciliacion],
|
||||
);
|
||||
console.log(`\nConciliación vinculada:`);
|
||||
for (const r of conc) console.log(' ', r);
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
main().catch(async (err) => {
|
||||
console.error('Fatal:', err);
|
||||
await prisma.$disconnect().catch(() => {});
|
||||
process.exit(1);
|
||||
});
|
||||
66
apps/api/scripts/inspect-facturapi-invoice.ts
Normal file
66
apps/api/scripts/inspect-facturapi-invoice.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Inspect the shape of the response from Facturapi invoices.retrieve
|
||||
* for a recent emission, to know what fields are actually populated.
|
||||
*/
|
||||
import { prisma, tenantDb } from '../src/config/database.js';
|
||||
import { env } from '../src/config/env.js';
|
||||
|
||||
const CONTRIB_ID = '414b22a8-c6e2-4f39-be0f-7537a848107e';
|
||||
const TENANT_RFC = 'DESPACHO_MO3NI6U8_B9VGG';
|
||||
const INVOICE_ID = '69ebc61f87f122486514c3b4'; // latest
|
||||
|
||||
async function main() {
|
||||
const tenant = await prisma.tenant.findFirst({
|
||||
where: { rfc: TENANT_RFC },
|
||||
select: { id: true, databaseName: true },
|
||||
});
|
||||
if (!tenant) return;
|
||||
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
|
||||
|
||||
// Fetch org API key
|
||||
const { rows } = await pool.query<{ facturapi_org_id: string }>(
|
||||
`SELECT facturapi_org_id FROM facturapi_orgs WHERE contribuyente_id=$1 AND active=true`,
|
||||
[CONTRIB_ID],
|
||||
);
|
||||
if (rows.length === 0) {
|
||||
console.log('No facturapi_org_id found');
|
||||
return;
|
||||
}
|
||||
const orgId = rows[0].facturapi_org_id;
|
||||
|
||||
// Get the org's API key (HTTP direct because SDK has issues)
|
||||
const userKey = env.FACTURAPI_USER_KEY;
|
||||
const keyRes = await fetch(`https://www.facturapi.io/v2/organizations/${orgId}/apikeys/test`, {
|
||||
headers: { Authorization: `Bearer ${userKey}` },
|
||||
});
|
||||
const keyData = await keyRes.json();
|
||||
const apiKey = typeof keyData === 'string' ? keyData : keyData.apikey || keyData.key;
|
||||
|
||||
// Retrieve the invoice
|
||||
const invRes = await fetch(`https://www.facturapi.io/v2/invoices/${INVOICE_ID}`, {
|
||||
headers: { Authorization: `Bearer ${apiKey}` },
|
||||
});
|
||||
const invoice = await invRes.json();
|
||||
|
||||
console.log('=== FACTURAPI INVOICE RESPONSE ===');
|
||||
console.log('Top-level keys:', Object.keys(invoice).sort().join(', '));
|
||||
console.log('');
|
||||
console.log('invoice.id =', invoice.id);
|
||||
console.log('invoice.uuid =', invoice.uuid);
|
||||
console.log('invoice.date =', invoice.date);
|
||||
console.log('invoice.subtotal =', invoice.subtotal);
|
||||
console.log('invoice.total =', invoice.total);
|
||||
console.log('invoice.series =', invoice.series);
|
||||
console.log('invoice.folio_number =', invoice.folio_number);
|
||||
console.log('invoice.issuer =', JSON.stringify(invoice.issuer, null, 2));
|
||||
console.log('invoice.issuer_info =', JSON.stringify(invoice.issuer_info, null, 2));
|
||||
console.log('invoice.issuer_type =', invoice.issuer_type);
|
||||
console.log('invoice.organization =', JSON.stringify(invoice.organization, null, 2));
|
||||
console.log('invoice.customer =', JSON.stringify(invoice.customer, null, 2));
|
||||
console.log('invoice.taxes =', JSON.stringify(invoice.taxes, null, 2));
|
||||
console.log('invoice.items =', JSON.stringify(invoice.items?.slice(0, 2), null, 2));
|
||||
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });
|
||||
41
apps/api/scripts/inspect-latest-facturapi.ts
Normal file
41
apps/api/scripts/inspect-latest-facturapi.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { prisma, tenantDb } from '../src/config/database.js';
|
||||
|
||||
const TENANT_RFC = 'DESPACHO_MO3NI6U8_B9VGG';
|
||||
|
||||
async function main() {
|
||||
const tenant = await prisma.tenant.findFirst({
|
||||
where: { rfc: TENANT_RFC },
|
||||
select: { id: true, databaseName: true },
|
||||
});
|
||||
if (!tenant) return;
|
||||
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
|
||||
|
||||
// Get the full latest Facturapi CFDI with ALL fields
|
||||
const { rows } = await pool.query(
|
||||
`SELECT * FROM cfdis
|
||||
WHERE source = 'facturapi'
|
||||
ORDER BY fecha_emision DESC
|
||||
LIMIT 1`,
|
||||
);
|
||||
if (rows.length === 0) {
|
||||
console.log('No hay CFDIs Facturapi');
|
||||
return;
|
||||
}
|
||||
|
||||
const r = rows[0];
|
||||
console.log('UUID:', r.uuid);
|
||||
console.log('');
|
||||
console.log('Campos relevantes de emisor/receptor:');
|
||||
const keys = Object.keys(r).sort();
|
||||
for (const k of keys) {
|
||||
if (/emisor|receptor|regimen|contribuyente|type|tipo|facturapi|uso_cfdi|forma|metodo|total|iva|lugar|fecha|status|version|uuid|id|source|serie|folio|xml_original/i.test(k)) {
|
||||
const v = r[k];
|
||||
const val = typeof v === 'string' && v.length > 200 ? v.substring(0, 200) + '…' : v;
|
||||
console.log(` ${k} = ${val instanceof Date ? val.toISOString() : String(val).substring(0, 200)}`);
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });
|
||||
52
apps/api/scripts/inspect-pair.ts
Normal file
52
apps/api/scripts/inspect-pair.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { prisma, tenantDb } from '../src/config/database.js';
|
||||
|
||||
const I_UUID = '5c874749-748f-11f0-96b1-2b9310891836';
|
||||
const E_UUID = '7163da3b-748f-11f0-9853-e97a8e1dedd9';
|
||||
|
||||
async function main() {
|
||||
const tenants = await prisma.tenant.findMany({ select: { id: true, rfc: true, databaseName: true } });
|
||||
|
||||
for (const t of tenants) {
|
||||
let pool;
|
||||
try { pool = await tenantDb.getPool(t.id, t.databaseName); } catch { continue; }
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT uuid, tipo_comprobante, metodo_pago, cfdi_tipo_relacion, cfdis_relacionados,
|
||||
status, fecha_emision, total_mxn, iva_traslado_mxn,
|
||||
rfc_emisor, rfc_receptor, contribuyente_id, type
|
||||
FROM cfdis WHERE LOWER(uuid) IN (LOWER($1), LOWER($2))`,
|
||||
[I_UUID, E_UUID],
|
||||
);
|
||||
|
||||
if (rows.length === 0) continue;
|
||||
console.log(`\n=== ${t.rfc} ===`);
|
||||
for (const r of rows) {
|
||||
const fe = new Date(r.fecha_emision).toISOString().slice(0, 10);
|
||||
console.log(`\n UUID: ${r.uuid}`);
|
||||
console.log(` tipo: ${r.tipo_comprobante}/${r.metodo_pago || '?'} rel=${r.cfdi_tipo_relacion ?? 'null'} status=${r.status} type=${r.type}`);
|
||||
console.log(` fecha: ${fe} total=${r.total_mxn} IVA=${r.iva_traslado_mxn}`);
|
||||
console.log(` ${r.rfc_emisor} → ${r.rfc_receptor} contrib_id=${r.contribuyente_id}`);
|
||||
console.log(` cfdis_relacionados: ${r.cfdis_relacionados ?? 'NULL'}`);
|
||||
}
|
||||
|
||||
// Si están ambos, verificar match de cfdis_relacionados
|
||||
if (rows.length === 2) {
|
||||
const i = rows.find((x: any) => x.uuid.toLowerCase() === I_UUID.toLowerCase());
|
||||
const e = rows.find((x: any) => x.uuid.toLowerCase() === E_UUID.toLowerCase());
|
||||
if (i && e) {
|
||||
const iRels = (i.cfdis_relacionados || '').split('|').map((u: string) => u.trim().toLowerCase()).filter(Boolean);
|
||||
const eRels = (e.cfdis_relacionados || '').split('|').map((u: string) => u.trim().toLowerCase()).filter(Boolean);
|
||||
const overlap = iRels.filter((u: string) => eRels.includes(u));
|
||||
console.log(`\n I refs (${iRels.length}): ${iRels.join(', ').substring(0, 200)}`);
|
||||
console.log(` E refs (${eRels.length}): ${eRels.join(', ').substring(0, 200)}`);
|
||||
console.log(` Overlap (${overlap.length}): ${overlap.join(', ')}`);
|
||||
|
||||
// Cruz: ¿la E referencia a la I directamente, o viceversa?
|
||||
if (eRels.includes(I_UUID.toLowerCase())) console.log(` → E.cfdis_relacionados INCLUYE el UUID de I/07 PPD`);
|
||||
if (iRels.includes(E_UUID.toLowerCase())) console.log(` → I.cfdis_relacionados INCLUYE el UUID de E`);
|
||||
}
|
||||
}
|
||||
}
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });
|
||||
48
apps/api/scripts/inspect-rfc.ts
Normal file
48
apps/api/scripts/inspect-rfc.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { prisma, tenantDb } from '../src/config/database.js';
|
||||
|
||||
const rawRfc = process.argv[2];
|
||||
if (!rawRfc) {
|
||||
console.error('Usage: tsx scripts/inspect-rfc.ts <rfc>');
|
||||
process.exit(1);
|
||||
}
|
||||
const rfc = rawRfc.toUpperCase();
|
||||
|
||||
async function main() {
|
||||
const tenants = await prisma.tenant.findMany({
|
||||
where: { active: true },
|
||||
select: { id: true, rfc: true, databaseName: true },
|
||||
});
|
||||
|
||||
for (const t of tenants) {
|
||||
const pool = await tenantDb.getPool(t.id, t.databaseName);
|
||||
|
||||
const { rows: contrib } = await pool.query(
|
||||
`SELECT * FROM contribuyentes WHERE UPPER(rfc) = $1`,
|
||||
[rfc],
|
||||
);
|
||||
if (contrib.length > 0) {
|
||||
console.log(`\n[${t.rfc}] Contribuyente ${rfc}:`);
|
||||
console.log(contrib[0]);
|
||||
}
|
||||
|
||||
const { rows: rfcEntry } = await pool.query(
|
||||
`SELECT id, rfc, razon_social, regimen_fiscal, codigo_postal FROM rfcs WHERE UPPER(rfc) = $1`,
|
||||
[rfc],
|
||||
);
|
||||
if (rfcEntry.length > 0) {
|
||||
console.log(`[${t.rfc}] rfcs table:`, rfcEntry[0]);
|
||||
}
|
||||
|
||||
if (contrib.length > 0) {
|
||||
const { rows: org } = await pool.query(
|
||||
`SELECT facturapi_org_id, csd_uploaded, active FROM facturapi_orgs WHERE contribuyente_id = $1`,
|
||||
[contrib[0].entidad_id],
|
||||
);
|
||||
if (org.length > 0) console.log(`[${t.rfc}] facturapi_orgs:`, org[0]);
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });
|
||||
159
apps/api/scripts/invalidate-metricas-all.ts
Normal file
159
apps/api/scripts/invalidate-metricas-all.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Invalida TODAS las entradas en `metricas_mensuales` — marca para recompute
|
||||
* cada (contribuyente_id, anio, mes) que tenga datos cacheados. Diseñado para
|
||||
* usarse después de un cambio de fórmula que afecta resultados históricos
|
||||
* (ej. 2026-04-23: NC tipo E con TipoRelacion=07 dejan de restar en Grupo 1).
|
||||
*
|
||||
* El cron `metricas-invalidations.job` (cada 15min) procesa el backlog.
|
||||
* Para acelerar: `pnpm --filter @horux/api exec tsx -e "import { runProcessInvalidations } from './src/jobs/metricas-invalidations.job.js'; runProcessInvalidations().then(()=>process.exit(0))"`
|
||||
*
|
||||
* Uso:
|
||||
* pnpm --filter @horux/api exec tsx scripts/invalidate-metricas-all.ts # ejecuta
|
||||
* pnpm --filter @horux/api exec tsx scripts/invalidate-metricas-all.ts --dry # reporta sin escribir
|
||||
*/
|
||||
import { prisma, tenantDb } from '../src/config/database.js';
|
||||
|
||||
const DRY_RUN = process.argv.includes('--dry') || process.argv.includes('--dry-run');
|
||||
const REASON = process.argv.find(a => a.startsWith('--reason='))?.slice(9) || 'FORMULA_CHANGE_E07_GRUPO1';
|
||||
|
||||
interface PerTenantResult {
|
||||
tenantId: string;
|
||||
rfc: string;
|
||||
databaseName: string;
|
||||
metricasRows: number;
|
||||
marcadasNuevas: number;
|
||||
marcadasUpdate: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
async function invalidateTenant(
|
||||
tenantId: string,
|
||||
rfc: string,
|
||||
databaseName: string,
|
||||
): Promise<PerTenantResult> {
|
||||
const result: PerTenantResult = {
|
||||
tenantId,
|
||||
rfc,
|
||||
databaseName,
|
||||
metricasRows: 0,
|
||||
marcadasNuevas: 0,
|
||||
marcadasUpdate: 0,
|
||||
};
|
||||
|
||||
const pool = await tenantDb.getPool(tenantId, databaseName);
|
||||
|
||||
// Cuenta filas existentes en metricas_mensuales para reportar
|
||||
const { rows: cnt } = await pool.query<{ n: number }>(
|
||||
`SELECT COUNT(DISTINCT (contribuyente_id, anio, mes))::int AS n FROM metricas_mensuales`,
|
||||
);
|
||||
result.metricasRows = cnt[0]?.n || 0;
|
||||
if (result.metricasRows === 0) return result;
|
||||
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
// Insert-or-update: si ya estaba marcada, sobrescribe reason y marcado_at
|
||||
// para que el cron la re-procese con el motivo correcto.
|
||||
const { rows: inserted } = await client.query<{
|
||||
contribuyente_id: string;
|
||||
anio: number;
|
||||
mes: number;
|
||||
was_new: boolean;
|
||||
}>(
|
||||
`
|
||||
INSERT INTO metricas_invalidaciones (contribuyente_id, anio, mes, reason)
|
||||
SELECT DISTINCT contribuyente_id, anio, mes, $1 AS reason
|
||||
FROM metricas_mensuales
|
||||
ON CONFLICT (contribuyente_id, anio, mes) DO UPDATE
|
||||
SET reason = EXCLUDED.reason, marcado_at = now()
|
||||
RETURNING contribuyente_id, anio, mes, (xmax = 0) AS was_new
|
||||
`,
|
||||
[REASON],
|
||||
);
|
||||
|
||||
result.marcadasNuevas = inserted.filter(r => r.was_new).length;
|
||||
result.marcadasUpdate = inserted.length - result.marcadasNuevas;
|
||||
|
||||
if (DRY_RUN) {
|
||||
await client.query('ROLLBACK');
|
||||
} else {
|
||||
await client.query('COMMIT');
|
||||
}
|
||||
} catch (err: any) {
|
||||
await client.query('ROLLBACK').catch(() => {});
|
||||
result.error = err?.message || String(err);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`=== Invalidate metricas_mensuales ${DRY_RUN ? '(DRY RUN — no writes)' : ''} ===`);
|
||||
console.log(`Reason: ${REASON}\n`);
|
||||
|
||||
const tenants = await prisma.tenant.findMany({
|
||||
where: { active: true },
|
||||
select: { id: true, rfc: true, databaseName: true },
|
||||
orderBy: { rfc: 'asc' },
|
||||
});
|
||||
|
||||
console.log(`Tenants activos: ${tenants.length}\n`);
|
||||
|
||||
const results: PerTenantResult[] = [];
|
||||
for (const t of tenants) {
|
||||
process.stdout.write(`[${t.rfc}] (${t.databaseName}) ... `);
|
||||
try {
|
||||
const r = await invalidateTenant(t.id, t.rfc, t.databaseName);
|
||||
results.push(r);
|
||||
if (r.error) {
|
||||
console.log(`ERROR: ${r.error}`);
|
||||
} else if (r.metricasRows === 0) {
|
||||
console.log(`sin cache (skip)`);
|
||||
} else {
|
||||
console.log(
|
||||
`cache=${r.metricasRows} (contrib,año,mes), marcadas=${r.marcadasNuevas + r.marcadasUpdate} (nuevas=${r.marcadasNuevas}, re-marcadas=${r.marcadasUpdate})`,
|
||||
);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.log(`FATAL: ${err?.message || err}`);
|
||||
results.push({
|
||||
tenantId: t.id,
|
||||
rfc: t.rfc,
|
||||
databaseName: t.databaseName,
|
||||
metricasRows: 0,
|
||||
marcadasNuevas: 0,
|
||||
marcadasUpdate: 0,
|
||||
error: err?.message || String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const totalMetricas = results.reduce((s, r) => s + r.metricasRows, 0);
|
||||
const totalMarcadas = results.reduce((s, r) => s + r.marcadasNuevas + r.marcadasUpdate, 0);
|
||||
const tenantsTouched = results.filter(r => r.marcadasNuevas + r.marcadasUpdate > 0).length;
|
||||
const tenantsFailed = results.filter(r => r.error).length;
|
||||
|
||||
console.log(`\n=== Resumen ===`);
|
||||
console.log(` Tenants procesados: ${results.length}`);
|
||||
console.log(` Tenants con cache: ${tenantsTouched}`);
|
||||
console.log(` Filas cache total: ${totalMetricas}`);
|
||||
console.log(` Invalidaciones: ${totalMarcadas}${DRY_RUN ? ' (rolled back)' : ''}`);
|
||||
if (tenantsFailed > 0) console.log(` Tenants con error: ${tenantsFailed}`);
|
||||
|
||||
if (!DRY_RUN && totalMarcadas > 0) {
|
||||
console.log(`\nCron metricas-invalidations procesará el backlog en <=15 min.`);
|
||||
console.log(`Para disparar manual: runProcessInvalidations() desde un tsx -e ad-hoc.`);
|
||||
}
|
||||
|
||||
await prisma.$disconnect();
|
||||
process.exit(tenantsFailed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
main().catch(async (err) => {
|
||||
console.error('Fatal:', err);
|
||||
await prisma.$disconnect().catch(() => {});
|
||||
process.exit(1);
|
||||
});
|
||||
26
apps/api/scripts/list-contribuyentes.ts
Normal file
26
apps/api/scripts/list-contribuyentes.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { prisma, tenantDb } from '../src/config/database.js';
|
||||
|
||||
async function main() {
|
||||
const tenants = await prisma.tenant.findMany({ where: { active: true }, select: { id: true, rfc: true, databaseName: true } });
|
||||
for (const t of tenants) {
|
||||
const pool = await tenantDb.getPool(t.id, t.databaseName);
|
||||
// descubrir tablas con 'entidad' o 'contribuyente' en el nombre
|
||||
const { rows: tbls } = await pool.query(`SELECT table_name FROM information_schema.tables WHERE table_schema='public' AND (table_name LIKE '%entidad%' OR table_name LIKE '%contribuyente%') ORDER BY table_name`);
|
||||
console.log(`\n[${t.rfc}] tablas:`, tbls.map((r: any) => r.table_name).join(', '));
|
||||
|
||||
// Join con rfcs si existe
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT c.entidad_id, c.rfc, r.razon_social, c.regimen_fiscal, c.codigo_postal
|
||||
FROM contribuyentes c
|
||||
LEFT JOIN rfcs r ON UPPER(r.rfc) = UPPER(c.rfc)
|
||||
ORDER BY r.razon_social NULLS LAST, c.rfc`,
|
||||
);
|
||||
for (const r of rows) console.log(' ', r);
|
||||
} catch (e: any) {
|
||||
console.log(' ERR:', e.message);
|
||||
}
|
||||
}
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });
|
||||
33
apps/api/scripts/migrate-tenants.ts
Normal file
33
apps/api/scripts/migrate-tenants.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Eager tenant migration script.
|
||||
* Run: pnpm --filter @horux/api db:migrate-tenants
|
||||
* Or: pnpm db:migrate-tenants (from monorepo root via Turborepo)
|
||||
*
|
||||
* Applies pending SQL migrations to all active tenant databases.
|
||||
*/
|
||||
import { migrateAll } from '../src/config/tenant-migrations.js';
|
||||
|
||||
async function main() {
|
||||
console.log('=== Tenant Schema Migration (Eager) ===\n');
|
||||
|
||||
const start = Date.now();
|
||||
const result = await migrateAll();
|
||||
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
|
||||
|
||||
console.log(`\n=== Done in ${elapsed}s ===`);
|
||||
console.log(` Migrated: ${result.success}`);
|
||||
console.log(` Up-to-date: ${result.skipped}`);
|
||||
console.log(` Failed: ${result.failed}`);
|
||||
|
||||
if (result.failed > 0) {
|
||||
console.error('\nSome tenants failed migration. Check logs above.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('Fatal error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
27
apps/api/scripts/otf-ingresos.ts
Normal file
27
apps/api/scripts/otf-ingresos.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
process.env.METRICAS_BYPASS_CACHE = '1';
|
||||
import { prisma, tenantDb } from '../src/config/database.js';
|
||||
import { calcularIngresosPorRegimen } from '../src/services/dashboard.service.js';
|
||||
|
||||
const tenantRfc = process.argv[2] || 'DESPACHO_MO3NI6U8_B9VGG';
|
||||
const contribuyenteId = process.argv[3] || 'b3761db6-0b8d-4251-8078-4ddc31e9c75b';
|
||||
const yearMonth = process.argv[4] || '2025-05';
|
||||
|
||||
async function main() {
|
||||
const tenant = await prisma.tenant.findFirst({ where: { rfc: tenantRfc }, select: { id: true, databaseName: true } });
|
||||
if (!tenant) return;
|
||||
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
|
||||
|
||||
const [anio, mes] = yearMonth.split('-').map(Number);
|
||||
const lastDay = new Date(anio, mes, 0).getDate();
|
||||
const fi = `${yearMonth}-01`;
|
||||
const ff = `${yearMonth}-${String(lastDay).padStart(2, '0')}`;
|
||||
|
||||
const r = await calcularIngresosPorRegimen(pool, tenant.id, fi, ff, undefined, undefined, false, contribuyenteId);
|
||||
console.log(`\n=== Ingresos ${yearMonth} contrib=${contribuyenteId} (BYPASS_CACHE=1) ===`);
|
||||
console.log(`Total: ${r.total.toFixed(2)}`);
|
||||
for (const p of r.porRegimen) {
|
||||
console.log(` ${p.regimenClave} (${p.regimenDescripcion}): ${p.monto.toFixed(2)}`);
|
||||
}
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });
|
||||
164
apps/api/scripts/preview-emails.mjs
Normal file
164
apps/api/scripts/preview-emails.mjs
Normal file
@@ -0,0 +1,164 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Genera los 8 templates de email como archivos HTML estáticos en
|
||||
* `apps/api/email-previews/` para revisar el diseño en el navegador
|
||||
* sin necesidad de SMTP configurado.
|
||||
*
|
||||
* Uso:
|
||||
* pnpm email:preview
|
||||
*
|
||||
* Tras correr, abre `apps/api/email-previews/index.html` para ver
|
||||
* el listado con links a cada template.
|
||||
*/
|
||||
import { writeFileSync, mkdirSync, rmSync } from 'node:fs';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = resolve(__dirname, '..');
|
||||
const OUT_DIR = resolve(ROOT, 'email-previews');
|
||||
|
||||
// Datos de ejemplo realistas para cada template
|
||||
const SAMPLES = {
|
||||
'welcome.html': {
|
||||
label: 'Bienvenida',
|
||||
fixture: { nombre: 'Carlos Hernández', email: 'carlos@empresa.com', tempPassword: 'a3f2c891' },
|
||||
importPath: '../src/services/email/templates/welcome.ts',
|
||||
fnName: 'welcomeEmail',
|
||||
},
|
||||
'password-reset.html': {
|
||||
label: 'Recuperación de contraseña',
|
||||
fixture: { nombre: 'Carlos Hernández', resetUrl: 'https://horuxfin.com/reset-password?token=a8e4f...' },
|
||||
importPath: '../src/services/email/templates/password-reset.ts',
|
||||
fnName: 'passwordResetEmail',
|
||||
},
|
||||
'payment-confirmed.html': {
|
||||
label: 'Pago confirmado',
|
||||
fixture: { nombre: 'Carlos Hernández', amount: 780, plan: 'Business + IA', date: new Date().toLocaleDateString('es-MX') },
|
||||
importPath: '../src/services/email/templates/payment-confirmed.ts',
|
||||
fnName: 'paymentConfirmedEmail',
|
||||
},
|
||||
'payment-failed.html': {
|
||||
label: 'Pago rechazado',
|
||||
fixture: { nombre: 'Carlos Hernández', amount: 780, plan: 'Business + IA' },
|
||||
importPath: '../src/services/email/templates/payment-failed.ts',
|
||||
fnName: 'paymentFailedEmail',
|
||||
},
|
||||
'subscription-cancelled.html': {
|
||||
label: 'Suscripción cancelada',
|
||||
fixture: { nombre: 'Carlos Hernández', plan: 'Business + IA' },
|
||||
importPath: '../src/services/email/templates/subscription-cancelled.ts',
|
||||
fnName: 'subscriptionCancelledEmail',
|
||||
},
|
||||
'subscription-expiring.html': {
|
||||
label: 'Suscripción por vencer',
|
||||
fixture: { nombre: 'Carlos Hernández', plan: 'Business + IA', expiresAt: '15 de mayo, 2026' },
|
||||
importPath: '../src/services/email/templates/subscription-expiring.ts',
|
||||
fnName: 'subscriptionExpiringEmail',
|
||||
},
|
||||
'fiel-notification.html': {
|
||||
label: 'e.firma cargada (admin)',
|
||||
fixture: { clienteNombre: 'Empresa Demo SA de CV', clienteRfc: 'EDE123456AB1' },
|
||||
importPath: '../src/services/email/templates/fiel-notification.ts',
|
||||
fnName: 'fielNotificationEmail',
|
||||
},
|
||||
'weekly-update.html': {
|
||||
label: 'Actualización semanal',
|
||||
fixture: {
|
||||
nombre: 'Carlos Hernández',
|
||||
empresa: 'Empresa Demo SA de CV',
|
||||
periodoLabel: 'Abril 2026',
|
||||
kpis: {
|
||||
ingresos: 285430.50,
|
||||
egresos: 142900.00,
|
||||
utilidad: 142530.50,
|
||||
margen: 49.9,
|
||||
ivaBalance: 18420.00,
|
||||
ivaAFavorAcumulado: 32100.00,
|
||||
cfdisEmitidos: 47,
|
||||
cfdisRecibidos: 23,
|
||||
},
|
||||
alertas: [
|
||||
{ titulo: 'Cliente en lista negra', mensaje: '1 cliente con situación SAT "Definitivo".', prioridad: 'alta' },
|
||||
{ titulo: 'Concentración alta de proveedores', mensaje: 'IHH = 6,840. Más del 50% del gasto en 1 proveedor.', prioridad: 'media' },
|
||||
{ titulo: 'Pago en efectivo', mensaje: '3 facturas recibidas con forma de pago "01-Efectivo" este mes.', prioridad: 'baja' },
|
||||
],
|
||||
discrepanciasPorMes: [
|
||||
{ label: 'Abril 2026', count: 2 },
|
||||
{ label: 'Marzo 2026', count: 5 },
|
||||
{ label: 'Febrero 2026', count: 0 },
|
||||
{ label: 'Enero 2026', count: 1 },
|
||||
],
|
||||
fechaGeneracion: new Date().toLocaleString('es-MX', { dateStyle: 'long', timeStyle: 'short' }),
|
||||
},
|
||||
importPath: '../src/services/email/templates/weekly-update.ts',
|
||||
fnName: 'weeklyUpdateEmail',
|
||||
},
|
||||
'new-client-admin.html': {
|
||||
label: 'Nuevo cliente registrado (admin)',
|
||||
fixture: {
|
||||
clienteNombre: 'Empresa Demo SA de CV',
|
||||
clienteRfc: 'EDE123456AB1',
|
||||
adminEmail: 'admin@empresademo.com',
|
||||
adminNombre: 'Carlos Hernández',
|
||||
tempPassword: 'a3f2c891',
|
||||
databaseName: 'horux_ede123456ab1',
|
||||
plan: '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);
|
||||
});
|
||||
32
apps/api/scripts/process-metricas-now.ts
Normal file
32
apps/api/scripts/process-metricas-now.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Dispara manualmente el procesamiento de `metricas_invalidaciones` para todos
|
||||
* los tenants. Útil tras un `invalidate-metricas-all.ts` para no esperar al
|
||||
* cron (cada 15 min).
|
||||
*
|
||||
* Uso:
|
||||
* pnpm --filter @horux/api exec tsx scripts/process-metricas-now.ts
|
||||
*/
|
||||
import { prisma } from '../src/config/database.js';
|
||||
import { processAllTenantsInvalidations } from '../src/services/metricas-compute.service.js';
|
||||
|
||||
async function main() {
|
||||
console.log('=== Procesar metricas_invalidaciones (all tenants) ===\n');
|
||||
const start = Date.now();
|
||||
const r = await processAllTenantsInvalidations();
|
||||
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
|
||||
console.log(
|
||||
`\nTenants revisados: ${r.tenantsRevisados}\n` +
|
||||
`Invalidaciones procesadas: ${r.totalProcesadas}\n` +
|
||||
`Filas metricas_mensuales escritas: ${r.totalFilasEscritas}\n` +
|
||||
`Errores: ${r.totalErrores}\n` +
|
||||
`Tiempo: ${elapsed}s`,
|
||||
);
|
||||
await prisma.$disconnect();
|
||||
process.exit(r.totalErrores > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
main().catch(async (err) => {
|
||||
console.error('Fatal:', err);
|
||||
await prisma.$disconnect().catch(() => {});
|
||||
process.exit(1);
|
||||
});
|
||||
59
apps/api/scripts/refresh-metricas-cache.ts
Normal file
59
apps/api/scripts/refresh-metricas-cache.ts
Normal 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);
|
||||
});
|
||||
100
apps/api/scripts/set-horux-custom.ts
Normal file
100
apps/api/scripts/set-horux-custom.ts
Normal 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);
|
||||
});
|
||||
71
apps/api/scripts/setup-despachos-db.ts
Normal file
71
apps/api/scripts/setup-despachos-db.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import bcrypt from 'bcryptjs';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
console.log('Setting up horux_despachos database...');
|
||||
|
||||
// Create admin user
|
||||
const hash = await bcrypt.hash('Admin12345!', 12);
|
||||
|
||||
const user = await prisma.user.upsert({
|
||||
where: { email: 'ivan@horuxfin.com' },
|
||||
update: {},
|
||||
create: {
|
||||
email: 'ivan@horuxfin.com',
|
||||
passwordHash: hash,
|
||||
nombre: 'Ivan Admin',
|
||||
},
|
||||
});
|
||||
console.log('✅ User created:', user.email);
|
||||
|
||||
// Find or create tenant
|
||||
let tenant = await prisma.tenant.findFirst();
|
||||
if (!tenant) {
|
||||
tenant = await prisma.tenant.create({
|
||||
data: {
|
||||
nombre: 'Despacho Demo',
|
||||
rfc: 'DDE250101AAA',
|
||||
plan: '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());
|
||||
47
apps/api/scripts/sweep-stale-sat-jobs.ts
Normal file
47
apps/api/scripts/sweep-stale-sat-jobs.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* CLI wrapper del watchdog. La lógica vive en
|
||||
* `src/services/sat/sweep-stale-jobs.service.ts` para que también se pueda
|
||||
* correr desde un cron (`sat-sync.job.ts`) sin duplicar código.
|
||||
*
|
||||
* Uso:
|
||||
* pnpm --filter @horux/api exec tsx scripts/sweep-stale-sat-jobs.ts # dry-run
|
||||
* pnpm --filter @horux/api exec tsx scripts/sweep-stale-sat-jobs.ts --apply # ejecuta
|
||||
* STALE_RUNNING_HOURS=2 pnpm --filter @horux/api exec tsx scripts/sweep-stale-sat-jobs.ts
|
||||
*/
|
||||
import { prisma } from '../src/config/database.js';
|
||||
import { sweepStaleSatJobs } from '../src/services/sat/sweep-stale-jobs.service.js';
|
||||
|
||||
async function main() {
|
||||
const apply = process.argv.includes('--apply');
|
||||
const pendingHours = Number(process.env.STALE_PENDING_HOURS || 12);
|
||||
const runningHours = Number(process.env.STALE_RUNNING_HOURS || 4);
|
||||
const mode = apply ? 'APPLY' : 'DRY-RUN';
|
||||
console.log(`=== SAT stale-jobs watchdog [${mode}] ===`);
|
||||
console.log(` pending: nextRetryAt < now − ${pendingHours}h`);
|
||||
console.log(` running: startedAt < now − ${runningHours}h`);
|
||||
console.log();
|
||||
|
||||
const result = await sweepStaleSatJobs({ apply, pendingHours, runningHours });
|
||||
|
||||
console.log(`Encontrados:`);
|
||||
console.log(` pending stale: ${result.pendingFound}`);
|
||||
console.log(` running stale: ${result.runningFound}`);
|
||||
|
||||
for (const e of result.entries) {
|
||||
console.log(` ─ ${e.id} tenant=${e.tenantId} kind=${e.kind} edad=${e.ageHours}h`);
|
||||
}
|
||||
|
||||
if (!apply) {
|
||||
console.log(`\n[DRY-RUN] No se aplicaron cambios. Pasa --apply para marcar como failed.`);
|
||||
} else {
|
||||
console.log(`\nMarcados como failed: pending=${result.pendingMarked} running=${result.runningMarked}`);
|
||||
}
|
||||
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
main().catch(async (err) => {
|
||||
console.error('Fatal:', err);
|
||||
await prisma.$disconnect().catch(() => {});
|
||||
process.exit(1);
|
||||
});
|
||||
96
apps/api/scripts/test-emails.ts
Normal file
96
apps/api/scripts/test-emails.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { emailService } from '../src/services/email/email.service.js';
|
||||
|
||||
const recipients = ['ivan@horuxfin.com', 'carlos@horuxfin.com'];
|
||||
|
||||
async function sendAllSamples() {
|
||||
for (const to of recipients) {
|
||||
console.log(`\n=== Enviando a ${to} ===`);
|
||||
|
||||
// 1. Welcome
|
||||
console.log('1/6 Bienvenida...');
|
||||
await emailService.sendWelcome(to, {
|
||||
nombre: 'Ivan Alcaraz',
|
||||
email: 'ivan@horuxfin.com',
|
||||
tempPassword: 'TempPass123!',
|
||||
});
|
||||
|
||||
// 2. FIEL notification (goes to ADMIN_EMAIL, but we override for test)
|
||||
console.log('2/6 Notificación FIEL...');
|
||||
// Send directly since sendFielNotification goes to admin
|
||||
const { fielNotificationEmail } = await import('../src/services/email/templates/fiel-notification.js');
|
||||
const { createTransport } = await import('nodemailer');
|
||||
const { env } = await import('../src/config/env.js');
|
||||
const transport = createTransport({
|
||||
host: env.SMTP_HOST,
|
||||
port: parseInt(env.SMTP_PORT),
|
||||
secure: false,
|
||||
auth: { user: env.SMTP_USER, pass: env.SMTP_PASS },
|
||||
});
|
||||
const fielHtml = fielNotificationEmail({
|
||||
clienteNombre: 'Horux 360',
|
||||
clienteRfc: 'CAS200101XXX',
|
||||
});
|
||||
await transport.sendMail({
|
||||
from: env.SMTP_FROM,
|
||||
to,
|
||||
subject: '[Horux 360] subió su FIEL (MUESTRA)',
|
||||
html: fielHtml,
|
||||
});
|
||||
|
||||
// 3. Payment confirmed
|
||||
console.log('3/6 Pago confirmado...');
|
||||
await emailService.sendPaymentConfirmed(to, {
|
||||
nombre: 'Ivan Alcaraz',
|
||||
amount: 1499,
|
||||
plan: 'Enterprise',
|
||||
date: '16 de marzo de 2026',
|
||||
});
|
||||
|
||||
// 4. Payment failed
|
||||
console.log('4/6 Pago fallido...');
|
||||
const { paymentFailedEmail } = await import('../src/services/email/templates/payment-failed.js');
|
||||
const failedHtml = paymentFailedEmail({
|
||||
nombre: 'Ivan Alcaraz',
|
||||
amount: 1499,
|
||||
plan: 'Enterprise',
|
||||
});
|
||||
await transport.sendMail({
|
||||
from: env.SMTP_FROM,
|
||||
to,
|
||||
subject: 'Problema con tu pago - Horux360 (MUESTRA)',
|
||||
html: failedHtml,
|
||||
});
|
||||
|
||||
// 5. Subscription expiring
|
||||
console.log('5/6 Suscripción por vencer...');
|
||||
await emailService.sendSubscriptionExpiring(to, {
|
||||
nombre: 'Ivan Alcaraz',
|
||||
plan: 'Enterprise',
|
||||
expiresAt: '21 de marzo de 2026',
|
||||
});
|
||||
|
||||
// 6. Subscription cancelled
|
||||
console.log('6/6 Suscripción cancelada...');
|
||||
const { subscriptionCancelledEmail } = await import('../src/services/email/templates/subscription-cancelled.js');
|
||||
const cancelledHtml = subscriptionCancelledEmail({
|
||||
nombre: 'Ivan Alcaraz',
|
||||
plan: 'Enterprise',
|
||||
});
|
||||
await transport.sendMail({
|
||||
from: env.SMTP_FROM,
|
||||
to,
|
||||
subject: 'Suscripción cancelada - Horux360 (MUESTRA)',
|
||||
html: cancelledHtml,
|
||||
});
|
||||
|
||||
console.log(`Listo: 6 correos enviados a ${to}`);
|
||||
}
|
||||
|
||||
console.log('\n=== Todos los correos enviados ===');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
sendAllSamples().catch((err) => {
|
||||
console.error('Error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
97
apps/api/scripts/validate-dashboard-impuestos.ts
Normal file
97
apps/api/scripts/validate-dashboard-impuestos.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Valida la alineación dashboard ≡ impuestos tras refactor de getResumenIva.
|
||||
* Para 5 muestras aleatorias por contribuyente, compara:
|
||||
* dashboard.calcularIvaBalancePorRegimen().total vs
|
||||
* impuestos.getResumenIva().resultado
|
||||
*
|
||||
* Deben coincidir céntimo por céntimo (Resultado = Trasladado − Acreditable − Retenido,
|
||||
* usando los mismos 6 buckets del dashboard).
|
||||
*
|
||||
* Uso:
|
||||
* pnpm --filter @horux/api exec tsx scripts/validate-dashboard-impuestos.ts
|
||||
* METRICAS_BYPASS_CACHE=1 pnpm --filter @horux/api exec tsx scripts/validate-dashboard-impuestos.ts
|
||||
*/
|
||||
import { prisma, tenantDb } from '../src/config/database.js';
|
||||
import * as dashboard from '../src/services/dashboard.service.js';
|
||||
import { getResumenIva } from '../src/services/impuestos.service.js';
|
||||
|
||||
const TOL = 0.01;
|
||||
|
||||
function cmp(a: number, b: number): boolean { return Math.abs(a - b) <= TOL; }
|
||||
function fmt(n: number): string {
|
||||
return n.toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('=== Validación dashboard.balance ≡ impuestos.resultado ===');
|
||||
console.log(` BYPASS_CACHE=${process.env.METRICAS_BYPASS_CACHE === '1' ? 'YES' : 'no'}\n`);
|
||||
|
||||
const tenants = await prisma.tenant.findMany({
|
||||
where: { active: true },
|
||||
select: { id: true, rfc: true, databaseName: true },
|
||||
});
|
||||
|
||||
let total = 0;
|
||||
let pass = 0;
|
||||
let fail = 0;
|
||||
|
||||
for (const t of tenants) {
|
||||
const pool = await tenantDb.getPool(t.id, t.databaseName);
|
||||
const { rows: contribs } = await pool.query<{ entidad_id: string; nombre: string }>(
|
||||
`SELECT c.entidad_id, eg.nombre
|
||||
FROM contribuyentes c
|
||||
JOIN entidades_gestionadas eg ON eg.id = c.entidad_id
|
||||
WHERE EXISTS (SELECT 1 FROM metricas_mensuales m WHERE m.contribuyente_id = c.entidad_id)`,
|
||||
);
|
||||
if (contribs.length === 0) continue;
|
||||
console.log(`[${t.rfc}] ${contribs.length} contribuyentes`);
|
||||
|
||||
for (const c of contribs) {
|
||||
const { rows: samples } = await pool.query<{ anio: number; mes: number }>(
|
||||
`SELECT anio, mes FROM (
|
||||
SELECT DISTINCT anio, mes FROM metricas_mensuales WHERE contribuyente_id = $1
|
||||
) t
|
||||
ORDER BY random() LIMIT 5`,
|
||||
[c.entidad_id],
|
||||
);
|
||||
console.log(` ${c.nombre}:`);
|
||||
|
||||
for (const s of samples) {
|
||||
total++;
|
||||
const fi = `${s.anio}-${String(s.mes).padStart(2, '0')}-01`;
|
||||
const lastDay = new Date(s.anio, s.mes, 0).getDate();
|
||||
const ff = `${s.anio}-${String(s.mes).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`;
|
||||
|
||||
const bal = await dashboard.calcularIvaBalancePorRegimen(
|
||||
pool, t.id, fi, ff, [], undefined, false, c.entidad_id,
|
||||
);
|
||||
const resumen = await getResumenIva(pool, fi, ff, t.id, false, c.entidad_id);
|
||||
|
||||
const mesLabel = `${s.anio}-${String(s.mes).padStart(2, '0')}`;
|
||||
if (cmp(bal.total, resumen.resultado)) {
|
||||
pass++;
|
||||
console.log(` ✓ ${mesLabel} balance=$${fmt(bal.total)} resultado=$${fmt(resumen.resultado)}`);
|
||||
} else {
|
||||
fail++;
|
||||
const delta = bal.total - resumen.resultado;
|
||||
console.log(` ✗ ${mesLabel} balance=$${fmt(bal.total)} resultado=$${fmt(resumen.resultado)} Δ=$${fmt(delta)}`);
|
||||
console.log(` T=$${fmt(resumen.trasladado)} A=$${fmt(resumen.acreditable)} R=$${fmt(resumen.retenido)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n=== Resumen ===`);
|
||||
console.log(` Muestras: ${total}`);
|
||||
console.log(` PASS: ${pass}`);
|
||||
console.log(` FAIL: ${fail}`);
|
||||
|
||||
await prisma.$disconnect();
|
||||
process.exit(fail > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
main().catch(async (err) => {
|
||||
console.error('Fatal:', err);
|
||||
await prisma.$disconnect().catch(() => {});
|
||||
process.exit(1);
|
||||
});
|
||||
115
apps/api/scripts/validate-gastos.ts
Normal file
115
apps/api/scripts/validate-gastos.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Compara Gastos del Dashboard vs Drill-down para un mes/contribuyente.
|
||||
* Identifica discrepancias y rompe el detalle por lado (factura/pago/NC).
|
||||
*
|
||||
* Uso: tsx scripts/validate-gastos.ts <tenantRfc> <entidadId> <añoMes>
|
||||
*/
|
||||
import { prisma, tenantDb } from '../src/config/database.js';
|
||||
import { calcularEgresosPorRegimen } from '../src/services/dashboard.service.js';
|
||||
|
||||
const tenantRfcArg = process.argv[2] || 'DESPACHO_MO3NI6U8_B9VGG';
|
||||
const contribuyenteId = process.argv[3] || 'd745a915-6a23-4818-944b-a7e1e18e536a';
|
||||
const yearMonth = process.argv[4] || '2025-02';
|
||||
|
||||
async function main() {
|
||||
const tenant = await prisma.tenant.findFirst({
|
||||
where: { rfc: tenantRfcArg, active: true },
|
||||
select: { id: true, rfc: true, databaseName: true },
|
||||
});
|
||||
if (!tenant) { console.error('Tenant not found'); process.exit(1); }
|
||||
|
||||
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
|
||||
|
||||
const [anio, mes] = yearMonth.split('-').map(Number);
|
||||
const lastDay = new Date(anio, mes, 0).getDate();
|
||||
const fi = `${yearMonth}-01`;
|
||||
const ff = `${yearMonth}-${String(lastDay).padStart(2, '0')}`;
|
||||
|
||||
console.log(`\n=== Contribuyente ${contribuyenteId} — ${fi} a ${ff} ===\n`);
|
||||
|
||||
// 1. Dashboard (calcularEgresosPorRegimen)
|
||||
const dashboard = await calcularEgresosPorRegimen(
|
||||
pool, tenant.id, fi, ff, undefined, undefined, false, contribuyenteId,
|
||||
);
|
||||
console.log('DASHBOARD calcularEgresosPorRegimen:');
|
||||
console.log(` total: ${dashboard.total.toFixed(2)}`);
|
||||
for (const r of dashboard.porRegimen) {
|
||||
console.log(` ${r.regimenClave} (${r.regimenDescripcion}): ${r.monto.toFixed(2)}`);
|
||||
}
|
||||
|
||||
// 2. Drill-down query (simulated — bucket=gastos uniforme)
|
||||
const IMP_TRAS = `COALESCE(iva_traslado_mxn,0) + COALESCE(ieps_traslado_mxn,0) + COALESCE(impuestos_locales_trasladado_mxn,0)`;
|
||||
const IMP_TRAS_PAGO = `COALESCE(iva_traslado_pago_mxn,0) + COALESCE(ieps_traslado_pago_mxn,0)`;
|
||||
const EXCL_MONTO = `COALESCE((SELECT SUM(COALESCE(cc.importe_mxn,0) - COALESCE(cc.descuento_mxn,0)) FROM cfdi_conceptos cc WHERE cc.cfdi_id = cfdis.id AND cc.clave_prod_serv IN ('84121603','93161608','85101501','85121800')), 0)`;
|
||||
|
||||
// bucket=gastos: RECIBIDO I PUE + RECIBIDO P + RECIBIDO E PUE (excl 07)
|
||||
// Sumamos tomando en cuenta el signo (E resta)
|
||||
const { rows: drillRows } = await pool.query(
|
||||
`SELECT
|
||||
type, tipo_comprobante, metodo_pago,
|
||||
COALESCE(cfdi_tipo_relacion, '') AS tipo_rel,
|
||||
COUNT(*)::int AS n,
|
||||
SUM(total_mxn) AS total_bruto,
|
||||
SUM(COALESCE(total_mxn,0) - (${IMP_TRAS}) - (${EXCL_MONTO})) AS total_neto,
|
||||
SUM(COALESCE(monto_pago_mxn,0) - (${IMP_TRAS_PAGO})) AS pago_neto
|
||||
FROM cfdis
|
||||
WHERE (
|
||||
(type = 'RECIBIDO' AND tipo_comprobante = 'I' AND metodo_pago = 'PUE'
|
||||
AND COALESCE(cfdi_tipo_relacion, '') <> '07')
|
||||
OR (type = 'RECIBIDO' AND tipo_comprobante = 'P')
|
||||
OR (type = 'RECIBIDO' AND tipo_comprobante = 'E' AND metodo_pago = 'PUE'
|
||||
AND COALESCE(cfdi_tipo_relacion, '') <> '07')
|
||||
)
|
||||
AND regimen_fiscal_receptor IN ('605','606','612','621','625','626','601','603','607','608','610','611','614','615','620','622','623','624')
|
||||
AND status NOT IN ('Cancelado','0')
|
||||
AND ((tipo_comprobante='P' AND fecha_pago_p >= $1::date AND fecha_pago_p < ($2::date + interval '1 day'))
|
||||
OR (tipo_comprobante!='P' AND fecha_emision >= $1::date AND fecha_emision < ($2::date + interval '1 day')))
|
||||
AND contribuyente_id = $3
|
||||
GROUP BY type, tipo_comprobante, metodo_pago, tipo_rel
|
||||
ORDER BY tipo_comprobante, metodo_pago`,
|
||||
[fi, ff, contribuyenteId],
|
||||
);
|
||||
|
||||
console.log(`\nDRILL-DOWN bucket=gastos (filas del drill por bucket):`);
|
||||
let drillSumaFacturas = 0, drillSumaPagos = 0, drillSumaNC = 0;
|
||||
for (const r of drillRows) {
|
||||
const tc = r.tipo_comprobante;
|
||||
const valor = tc === 'P' ? Number(r.pago_neto) : Number(r.total_neto);
|
||||
console.log(` ${r.type} ${tc} ${r.metodo_pago || '-'} rel=${r.tipo_rel || '-'} n=${r.n} total_bruto=${Number(r.total_bruto).toFixed(2)} valor_neto=${valor.toFixed(2)}`);
|
||||
if (tc === 'I') drillSumaFacturas += valor;
|
||||
else if (tc === 'P') drillSumaPagos += valor;
|
||||
else if (tc === 'E') drillSumaNC += valor;
|
||||
}
|
||||
const drillTotal = drillSumaFacturas + drillSumaPagos - drillSumaNC;
|
||||
console.log(` → facturas=${drillSumaFacturas.toFixed(2)} pagos=${drillSumaPagos.toFixed(2)} NC=${drillSumaNC.toFixed(2)}`);
|
||||
console.log(` → drill total = ${drillTotal.toFixed(2)}`);
|
||||
|
||||
// 3. Comparación
|
||||
const delta = dashboard.total - drillTotal;
|
||||
console.log(`\n=== COMPARATIVA ===`);
|
||||
console.log(` Dashboard: ${dashboard.total.toFixed(2)}`);
|
||||
console.log(` Drill-down: ${drillTotal.toFixed(2)}`);
|
||||
console.log(` Delta: ${delta.toFixed(2)}`);
|
||||
|
||||
if (Math.abs(delta) < 0.01) {
|
||||
console.log(` ✓ CUADRAN`);
|
||||
} else {
|
||||
console.log(` ✗ NO CUADRAN — investigar`);
|
||||
}
|
||||
|
||||
// 4. Régimenes del receptor que aparecen vs los ignorados
|
||||
const { rows: regsReceptor } = await pool.query(
|
||||
`SELECT DISTINCT regimen_fiscal_receptor
|
||||
FROM cfdis
|
||||
WHERE contribuyente_id = $1
|
||||
AND type = 'RECIBIDO'
|
||||
AND fecha_emision >= $2::date AND fecha_emision < ($3::date + interval '1 day')
|
||||
ORDER BY regimen_fiscal_receptor`,
|
||||
[contribuyenteId, fi, ff],
|
||||
);
|
||||
console.log(`\nRegímenes en CFDIs RECIBIDOS del periodo:`, regsReceptor.map(r => r.regimen_fiscal_receptor).join(', '));
|
||||
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });
|
||||
39
apps/api/scripts/validate-ingresos.ts
Normal file
39
apps/api/scripts/validate-ingresos.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Paridad dashboard vs drill para INGRESOS de un contribuyente en un año.
|
||||
* Similar a validate-gastos pero para el lado emisor.
|
||||
*/
|
||||
import { prisma, tenantDb } from '../src/config/database.js';
|
||||
import { calcularIngresosPorRegimen } from '../src/services/dashboard.service.js';
|
||||
|
||||
const tenantRfc = process.argv[2] || 'DESPACHO_MO3NI6U8_B9VGG';
|
||||
const contribuyenteId = process.argv[3] || '414b22a8-c6e2-4f39-be0f-7537a848107e';
|
||||
const año = Number(process.argv[4] || '2025');
|
||||
|
||||
async function main() {
|
||||
const tenant = await prisma.tenant.findFirst({ where: { rfc: tenantRfc }, select: { id: true, databaseName: true } });
|
||||
if (!tenant) { console.error('Tenant not found'); process.exit(1); }
|
||||
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
|
||||
|
||||
console.log(`\n=== Ingresos ${año} Contribuyente ${contribuyenteId} ===\n`);
|
||||
console.log(`mes | total por régimen | total mes`);
|
||||
|
||||
let totalAño = 0;
|
||||
for (let m = 1; m <= 12; m++) {
|
||||
const lastDay = new Date(año, m, 0).getDate();
|
||||
const mm = String(m).padStart(2, '0');
|
||||
const fi = `${año}-${mm}-01`;
|
||||
const ff = `${año}-${mm}-${String(lastDay).padStart(2, '0')}`;
|
||||
|
||||
const ingresos = await calcularIngresosPorRegimen(
|
||||
pool, tenant.id, fi, ff, undefined, undefined, false, contribuyenteId,
|
||||
);
|
||||
|
||||
const porReg = ingresos.porRegimen.map(r => `${r.regimenClave}:${r.monto.toFixed(2)}`).join(' / ');
|
||||
console.log(`${mm} | ${porReg || '(sin datos)'} | ${ingresos.total.toFixed(2)}`);
|
||||
totalAño += ingresos.total;
|
||||
}
|
||||
console.log(`\nTotal año: ${totalAño.toFixed(2)}`);
|
||||
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });
|
||||
160
apps/api/scripts/validate-metricas.ts
Normal file
160
apps/api/scripts/validate-metricas.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* Validación Tanda A: para cada contribuyente con datos en metricas_mensuales,
|
||||
* toma 5 filas al azar y compara contra el cálculo on-the-fly usando los
|
||||
* servicios canónicos (dashboard, impuestos). Reporta PASS/FAIL por celda.
|
||||
*
|
||||
* Uso:
|
||||
* pnpm --filter @horux/api exec tsx scripts/validate-metricas.ts
|
||||
*/
|
||||
import { prisma, tenantDb } from '../src/config/database.js';
|
||||
import {
|
||||
calcularIngresosPorRegimen,
|
||||
calcularEgresosPorRegimen,
|
||||
} from '../src/services/dashboard.service.js';
|
||||
import { getResumenIva } from '../src/services/impuestos.service.js';
|
||||
|
||||
const TOL = 0.01; // tolerancia de $0.01 para redondeo decimal
|
||||
|
||||
interface StoredRow {
|
||||
contribuyente_id: string;
|
||||
anio: number;
|
||||
mes: number;
|
||||
regimen_fiscal: string | null;
|
||||
ingresos_cobrados: string;
|
||||
egresos_pagados: string;
|
||||
iva_trasladado_total: string;
|
||||
iva_acreditable: string;
|
||||
iva_retenido_cobrado: string;
|
||||
iva_resultado: string;
|
||||
cfdis_emitidos_count: number;
|
||||
cfdis_recibidos_count: number;
|
||||
cfdis_cancelados_count: number;
|
||||
}
|
||||
|
||||
function cmp(a: number, b: number): boolean {
|
||||
return Math.abs(a - b) <= TOL;
|
||||
}
|
||||
|
||||
function fmt(n: number): string {
|
||||
return n.toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
async function validateRow(
|
||||
tenantId: string,
|
||||
row: StoredRow,
|
||||
): Promise<{ pass: boolean; diffs: string[] }> {
|
||||
const tenant = await prisma.tenant.findUnique({
|
||||
where: { id: tenantId },
|
||||
select: { databaseName: true },
|
||||
});
|
||||
if (!tenant) return { pass: false, diffs: ['tenant no encontrado'] };
|
||||
|
||||
const pool = await tenantDb.getPool(tenantId, tenant.databaseName);
|
||||
const fi = `${row.anio}-${String(row.mes).padStart(2, '0')}-01`;
|
||||
const lastDay = new Date(row.anio, row.mes, 0).getDate();
|
||||
const ff = `${row.anio}-${String(row.mes).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`;
|
||||
|
||||
// Ejecutamos secuencial para evitar interferencia entre queries bajo el pool
|
||||
// limit del tenant (max 3 conexiones). Con Promise.all concurrente, algunas
|
||||
// queries compartidas de getResumenIva devolvían valores parciales.
|
||||
const ingresos = await calcularIngresosPorRegimen(pool, tenantId, fi, ff, [], undefined, false, row.contribuyente_id);
|
||||
const egresos = await calcularEgresosPorRegimen(pool, tenantId, fi, ff, [], undefined, false, row.contribuyente_id);
|
||||
const resumenIva = await getResumenIva(pool, fi, ff, tenantId, false, row.contribuyente_id);
|
||||
|
||||
const reg = row.regimen_fiscal;
|
||||
const ingOtf = ingresos.porRegimen.find(r => r.regimenClave === reg)?.monto || 0;
|
||||
const egrOtf = egresos.porRegimen.find(r => r.regimenClave === reg)?.monto || 0;
|
||||
const trasOtf = resumenIva.trasladadoPorRegimen.find(r => r.regimenClave === reg)?.monto || 0;
|
||||
const acrOtf = resumenIva.acreditablePorRegimen.find(r => r.regimenClave === reg)?.monto || 0;
|
||||
const retOtf = resumenIva.retenidoPorRegimen.find(r => r.regimenClave === reg)?.monto || 0;
|
||||
const resOtf = trasOtf - acrOtf - retOtf;
|
||||
|
||||
const diffs: string[] = [];
|
||||
const ingStored = Number(row.ingresos_cobrados);
|
||||
const egrStored = Number(row.egresos_pagados);
|
||||
const trasStored = Number(row.iva_trasladado_total);
|
||||
const acrStored = Number(row.iva_acreditable);
|
||||
const retStored = Number(row.iva_retenido_cobrado);
|
||||
const resStored = Number(row.iva_resultado);
|
||||
|
||||
if (!cmp(ingStored, ingOtf)) diffs.push(`ingresos: tabla=${fmt(ingStored)} vs otf=${fmt(ingOtf)}`);
|
||||
if (!cmp(egrStored, egrOtf)) diffs.push(`egresos: tabla=${fmt(egrStored)} vs otf=${fmt(egrOtf)}`);
|
||||
if (!cmp(trasStored, trasOtf)) diffs.push(`ivaTras: tabla=${fmt(trasStored)} vs otf=${fmt(trasOtf)}`);
|
||||
if (!cmp(acrStored, acrOtf)) diffs.push(`ivaAcr: tabla=${fmt(acrStored)} vs otf=${fmt(acrOtf)}`);
|
||||
if (!cmp(retStored, retOtf)) diffs.push(`ivaRet: tabla=${fmt(retStored)} vs otf=${fmt(retOtf)}`);
|
||||
if (!cmp(resStored, resOtf)) diffs.push(`ivaResultado: tabla=${fmt(resStored)} vs otf=${fmt(resOtf)}`);
|
||||
|
||||
return { pass: diffs.length === 0, diffs };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('=== Validación metricas_mensuales (5 muestras aleatorias por contribuyente) ===\n');
|
||||
|
||||
const tenants = await prisma.tenant.findMany({
|
||||
where: { active: true },
|
||||
select: { id: true, rfc: true, databaseName: true },
|
||||
});
|
||||
|
||||
let totalMuestras = 0;
|
||||
let totalPass = 0;
|
||||
let totalFail = 0;
|
||||
|
||||
for (const t of tenants) {
|
||||
const pool = await tenantDb.getPool(t.id, t.databaseName);
|
||||
const { rows: contribs } = await pool.query<{ entidad_id: string; nombre: string }>(
|
||||
`SELECT c.entidad_id, eg.nombre
|
||||
FROM contribuyentes c
|
||||
JOIN entidades_gestionadas eg ON eg.id = c.entidad_id
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM metricas_mensuales m WHERE m.contribuyente_id = c.entidad_id
|
||||
)`,
|
||||
);
|
||||
|
||||
if (contribs.length === 0) continue;
|
||||
console.log(`\n[${t.rfc}] ${contribs.length} contribuyentes con datos`);
|
||||
|
||||
for (const c of contribs) {
|
||||
const { rows: samples } = await pool.query<StoredRow>(
|
||||
`SELECT contribuyente_id::text, anio, mes, regimen_fiscal,
|
||||
ingresos_cobrados, egresos_pagados,
|
||||
iva_trasladado_total, iva_acreditable, iva_retenido_cobrado, iva_resultado,
|
||||
cfdis_emitidos_count, cfdis_recibidos_count, cfdis_cancelados_count
|
||||
FROM metricas_mensuales
|
||||
WHERE contribuyente_id = $1
|
||||
ORDER BY random()
|
||||
LIMIT 5`,
|
||||
[c.entidad_id],
|
||||
);
|
||||
|
||||
console.log(` ${c.nombre} (${samples.length} muestras):`);
|
||||
for (const s of samples) {
|
||||
totalMuestras++;
|
||||
const { pass, diffs } = await validateRow(t.id, s);
|
||||
const mesLabel = `${s.anio}-${String(s.mes).padStart(2, '0')}`;
|
||||
const reg = s.regimen_fiscal || 'null';
|
||||
if (pass) {
|
||||
totalPass++;
|
||||
console.log(` ✓ ${mesLabel} reg=${reg} ingresos=$${fmt(Number(s.ingresos_cobrados))}`);
|
||||
} else {
|
||||
totalFail++;
|
||||
console.log(` ✗ ${mesLabel} reg=${reg} DIFFS:`);
|
||||
for (const d of diffs) console.log(` - ${d}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n=== Resumen ===`);
|
||||
console.log(` Muestras totales: ${totalMuestras}`);
|
||||
console.log(` PASS: ${totalPass}`);
|
||||
console.log(` FAIL: ${totalFail}`);
|
||||
|
||||
await prisma.$disconnect();
|
||||
process.exit(totalFail > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
main().catch(async (err) => {
|
||||
console.error('Fatal:', err);
|
||||
await prisma.$disconnect().catch(() => {});
|
||||
process.exit(1);
|
||||
});
|
||||
112
apps/api/src/app.ts
Normal file
112
apps/api/src/app.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import express, { type Express } from 'express';
|
||||
import cors from 'cors';
|
||||
import helmet from 'helmet';
|
||||
import { env, getCorsOrigins } from './config/env.js';
|
||||
import { errorMiddleware } from './middlewares/error.middleware.js';
|
||||
import { authRoutes } from './routes/auth.routes.js';
|
||||
import { dashboardRoutes } from './routes/dashboard.routes.js';
|
||||
import { cfdiRoutes } from './routes/cfdi.routes.js';
|
||||
import { impuestosRoutes } from './routes/impuestos.routes.js';
|
||||
import { exportRoutes } from './routes/export.routes.js';
|
||||
import { alertasRoutes } from './routes/alertas.routes.js';
|
||||
import { notificationPreferencesRoutes } from './routes/notification-preferences.routes.js';
|
||||
import { tareasRoutes } from './routes/tareas.routes.js';
|
||||
import { papeleriaRoutes } from './routes/papeleria.routes.js';
|
||||
import { despachoStatsRoutes } from './routes/despacho-stats.routes.js';
|
||||
import { calendarioRoutes } from './routes/calendario.routes.js';
|
||||
import { reportesRoutes } from './routes/reportes.routes.js';
|
||||
import { usuariosRoutes } from './routes/usuarios.routes.js';
|
||||
import { tenantsRoutes } from './routes/tenants.routes.js';
|
||||
import fielRoutes from './routes/fiel.routes.js';
|
||||
import satRoutes from './routes/sat.routes.js';
|
||||
import { webhookRoutes } from './routes/webhook.routes.js';
|
||||
import { subscriptionRoutes } from './routes/subscription.routes.js';
|
||||
import { regimenRoutes } from './routes/regimen.routes.js';
|
||||
import { bancosRoutes } from './routes/bancos.routes.js';
|
||||
import { conciliacionRoutes } from './routes/conciliacion.routes.js';
|
||||
import { facturacionRoutes } from './routes/facturacion.routes.js';
|
||||
import { catalogosRoutes } from './routes/catalogos.routes.js';
|
||||
import { documentosRoutes } from './routes/documentos.routes.js';
|
||||
import { auditLogRoutes } from './routes/audit-log.routes.js';
|
||||
import { platformStaffRoutes } from './routes/platform-staff.routes.js';
|
||||
import despachoRoutes from './routes/despacho.routes.js';
|
||||
import contribuyenteRoutes from './routes/contribuyente.routes.js';
|
||||
import carteraRoutes from './routes/cartera.routes.js';
|
||||
import planCatalogoRoutes from './routes/plan-catalogo.routes.js';
|
||||
import connectorRoutes from './routes/connector.routes.js';
|
||||
import adminDashboardRoutes from './routes/admin-dashboard.routes.js';
|
||||
import adminImpersonateRoutes from './routes/admin-impersonate.routes.js';
|
||||
import adminClientesRoutes from './routes/admin-clientes.routes.js';
|
||||
import adminAddonsRoutes from './routes/admin-addons.routes.js';
|
||||
import despachoAuditRoutes from './routes/despacho-audit.routes.js';
|
||||
import metricasRoutes from './routes/metricas.routes.js';
|
||||
|
||||
const app: Express = express();
|
||||
|
||||
// Security. Helmet default incluye un CSP restrictivo que puede chocar con el
|
||||
// frontend cuando éste embebe recursos propios (ej: /terminos embebe el PDF de
|
||||
// /legal/). Dejamos CSP off en el API y centralizamos los headers de seguridad
|
||||
// en next.config del web (X-Frame-Options, CSP frame-ancestors, HSTS, nosniff,
|
||||
// Referrer-Policy) que es quien sirve la UI. El API solo responde JSON y
|
||||
// archivos binarios (PDFs, XMLs) — no tiene contenido HTML que requiera CSP.
|
||||
app.use(helmet({
|
||||
contentSecurityPolicy: false,
|
||||
crossOriginResourcePolicy: { policy: 'cross-origin' }, // permite /legal/*.pdf embebido
|
||||
}));
|
||||
app.use(cors({
|
||||
origin: getCorsOrigins(),
|
||||
credentials: true,
|
||||
}));
|
||||
|
||||
// Body parsing - 10MB default, bulk CFDI route has its own higher limit
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
|
||||
|
||||
// Health check
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||
});
|
||||
|
||||
// API Routes
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/dashboard', dashboardRoutes);
|
||||
app.use('/api/cfdi', cfdiRoutes);
|
||||
app.use('/api/impuestos', impuestosRoutes);
|
||||
app.use('/api/export', exportRoutes);
|
||||
app.use('/api/alertas', alertasRoutes);
|
||||
app.use('/api/notificaciones', notificationPreferencesRoutes);
|
||||
app.use('/api/tareas', tareasRoutes);
|
||||
app.use('/api/papeleria', papeleriaRoutes);
|
||||
app.use('/api/despachos', despachoStatsRoutes);
|
||||
app.use('/api/calendario', calendarioRoutes);
|
||||
app.use('/api/reportes', reportesRoutes);
|
||||
app.use('/api/usuarios', usuariosRoutes);
|
||||
app.use('/api/tenants', tenantsRoutes);
|
||||
app.use('/api/fiel', fielRoutes);
|
||||
app.use('/api/sat', satRoutes);
|
||||
app.use('/api/webhooks', webhookRoutes);
|
||||
app.use('/api/subscriptions', subscriptionRoutes);
|
||||
app.use('/api/regimenes', regimenRoutes);
|
||||
app.use('/api/bancos', bancosRoutes);
|
||||
app.use('/api/conciliacion', conciliacionRoutes);
|
||||
app.use('/api/facturacion', facturacionRoutes);
|
||||
app.use('/api/catalogos', catalogosRoutes);
|
||||
app.use('/api/documentos', documentosRoutes);
|
||||
app.use('/api/audit-log', auditLogRoutes);
|
||||
app.use('/api/platform-staff', platformStaffRoutes);
|
||||
app.use('/api/despachos', despachoRoutes);
|
||||
app.use('/api/contribuyentes', contribuyenteRoutes);
|
||||
app.use('/api/carteras', carteraRoutes);
|
||||
app.use('/api/planes', planCatalogoRoutes);
|
||||
app.use('/api/connector', connectorRoutes);
|
||||
app.use('/api/admin/dashboard', adminDashboardRoutes);
|
||||
app.use('/api/admin/impersonate', adminImpersonateRoutes);
|
||||
app.use('/api/admin/clientes', adminClientesRoutes);
|
||||
app.use('/api/admin/addons', adminAddonsRoutes);
|
||||
app.use('/api/despacho/audit-log', despachoAuditRoutes);
|
||||
app.use('/api/metricas', metricasRoutes);
|
||||
|
||||
// Error handling
|
||||
app.use(errorMiddleware);
|
||||
|
||||
export { app };
|
||||
1
apps/api/src/auth/passwords.ts
Normal file
1
apps/api/src/auth/passwords.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { hashPassword, verifyPassword } from '@horux/core';
|
||||
30
apps/api/src/auth/tokens.ts
Normal file
30
apps/api/src/auth/tokens.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import {
|
||||
generateAccessToken as coreGenerateAccessToken,
|
||||
generateRefreshToken as coreGenerateRefreshToken,
|
||||
verifyToken as coreVerifyToken,
|
||||
decodeToken,
|
||||
type TokenConfig,
|
||||
} from '@horux/core';
|
||||
import type { JWTPayload } from '@horux/shared';
|
||||
import { env } from '../config/env.js';
|
||||
|
||||
const tokenConfig: TokenConfig = {
|
||||
secret: env.JWT_SECRET,
|
||||
accessExpiresIn: env.JWT_EXPIRES_IN,
|
||||
refreshExpiresIn: env.JWT_REFRESH_EXPIRES_IN,
|
||||
};
|
||||
|
||||
export function generateAccessToken(payload: Omit<JWTPayload, 'iat' | 'exp'>): string {
|
||||
return coreGenerateAccessToken(payload, tokenConfig);
|
||||
}
|
||||
|
||||
export function generateRefreshToken(payload: Omit<JWTPayload, 'iat' | 'exp'>): string {
|
||||
return coreGenerateRefreshToken(payload, tokenConfig);
|
||||
}
|
||||
|
||||
export function verifyToken(token: string): JWTPayload {
|
||||
return coreVerifyToken(token, tokenConfig.secret);
|
||||
}
|
||||
|
||||
export { decodeToken };
|
||||
export type { JWTPayload };
|
||||
234
apps/api/src/config/database.ts
Normal file
234
apps/api/src/config/database.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { Pool, type PoolConfig } from 'pg';
|
||||
import { env } from './env.js';
|
||||
import { migrate } from './tenant-migrations.js';
|
||||
|
||||
// ===========================================
|
||||
// Prisma Client (central database: horux360)
|
||||
// ===========================================
|
||||
|
||||
declare global {
|
||||
var prisma: PrismaClient | undefined;
|
||||
}
|
||||
|
||||
export const prisma = globalThis.prisma || new PrismaClient({
|
||||
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
globalThis.prisma = prisma;
|
||||
}
|
||||
|
||||
// ===========================================
|
||||
// TenantConnectionManager (per-tenant DBs)
|
||||
// ===========================================
|
||||
|
||||
interface PoolEntry {
|
||||
pool: Pool;
|
||||
lastAccess: Date;
|
||||
}
|
||||
|
||||
function parseDatabaseUrl(url: string) {
|
||||
const parsed = new URL(url);
|
||||
return {
|
||||
host: parsed.hostname,
|
||||
port: parseInt(parsed.port || '5432'),
|
||||
user: decodeURIComponent(parsed.username),
|
||||
password: decodeURIComponent(parsed.password),
|
||||
};
|
||||
}
|
||||
|
||||
class TenantConnectionManager {
|
||||
private pools: Map<string, PoolEntry> = new Map();
|
||||
private cleanupInterval: NodeJS.Timeout | null = null;
|
||||
private dbConfig: { host: string; port: number; user: string; password: string };
|
||||
private migratedPools: Set<string> = new Set();
|
||||
|
||||
constructor() {
|
||||
this.dbConfig = parseDatabaseUrl(env.DATABASE_URL);
|
||||
this.cleanupInterval = setInterval(() => this.cleanupIdlePools(), 60_000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create a connection pool for a tenant's database.
|
||||
* Runs lazy migrations on first access (or after pool invalidation).
|
||||
*/
|
||||
async getPool(
|
||||
tenantId: string,
|
||||
databaseName: string,
|
||||
connectionOverride?: { host: string; port: number; user: string; password: string },
|
||||
): Promise<Pool> {
|
||||
let pool: Pool;
|
||||
|
||||
const entry = this.pools.get(tenantId);
|
||||
if (entry) {
|
||||
entry.lastAccess = new Date();
|
||||
pool = entry.pool;
|
||||
} else {
|
||||
const poolConfig: PoolConfig = {
|
||||
host: connectionOverride?.host ?? this.dbConfig.host,
|
||||
port: connectionOverride?.port ?? this.dbConfig.port,
|
||||
user: connectionOverride?.user ?? this.dbConfig.user,
|
||||
password: connectionOverride?.password ?? this.dbConfig.password,
|
||||
database: databaseName,
|
||||
max: 3,
|
||||
idleTimeoutMillis: 300_000,
|
||||
connectionTimeoutMillis: 10_000,
|
||||
};
|
||||
|
||||
pool = new Pool(poolConfig);
|
||||
|
||||
pool.on('error', (err) => {
|
||||
console.error(`[TenantDB] Pool error for tenant ${tenantId} (${databaseName}):`, err.message);
|
||||
});
|
||||
|
||||
this.pools.set(tenantId, { pool, lastAccess: new Date() });
|
||||
}
|
||||
|
||||
if (!this.migratedPools.has(tenantId)) {
|
||||
try {
|
||||
await migrate(pool, databaseName);
|
||||
} catch (err) {
|
||||
console.error(`[TenantDB] Migration error for tenant ${tenantId} (${databaseName}):`, err);
|
||||
}
|
||||
this.migratedPools.add(tenantId);
|
||||
}
|
||||
|
||||
return pool;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new database for a tenant with all required tables and indexes.
|
||||
*/
|
||||
async provisionDatabase(rfc: string, overrideDatabaseName?: string): Promise<string> {
|
||||
const databaseName = overrideDatabaseName || `horux_${rfc.toLowerCase().replace(/[^a-z0-9]/g, '')}`;
|
||||
|
||||
const adminPool = new Pool({
|
||||
...this.dbConfig,
|
||||
database: 'postgres',
|
||||
max: 1,
|
||||
});
|
||||
|
||||
try {
|
||||
const exists = await adminPool.query(
|
||||
`SELECT 1 FROM pg_database WHERE datname = $1`,
|
||||
[databaseName]
|
||||
);
|
||||
|
||||
if (exists.rows.length > 0) {
|
||||
throw new Error(`Database ${databaseName} already exists`);
|
||||
}
|
||||
|
||||
await adminPool.query(`CREATE DATABASE "${databaseName}"`);
|
||||
|
||||
const tenantPool = new Pool({
|
||||
...this.dbConfig,
|
||||
database: databaseName,
|
||||
max: 1,
|
||||
});
|
||||
|
||||
try {
|
||||
await migrate(tenantPool, databaseName);
|
||||
} finally {
|
||||
await tenantPool.end();
|
||||
}
|
||||
|
||||
return databaseName;
|
||||
} finally {
|
||||
await adminPool.end();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft-delete: rename database so it can be recovered.
|
||||
*/
|
||||
async deprovisionDatabase(databaseName: string): Promise<void> {
|
||||
// Close any active pool for this tenant
|
||||
for (const [tenantId, entry] of this.pools.entries()) {
|
||||
// We check pool config to match the database
|
||||
if ((entry.pool as any).options?.database === databaseName) {
|
||||
await entry.pool.end().catch(() => {});
|
||||
this.pools.delete(tenantId);
|
||||
}
|
||||
}
|
||||
|
||||
const timestamp = Date.now();
|
||||
const adminPool = new Pool({
|
||||
...this.dbConfig,
|
||||
database: 'postgres',
|
||||
max: 1,
|
||||
});
|
||||
|
||||
try {
|
||||
await adminPool.query(`
|
||||
SELECT pg_terminate_backend(pid)
|
||||
FROM pg_stat_activity
|
||||
WHERE datname = $1 AND pid <> pg_backend_pid()
|
||||
`, [databaseName]);
|
||||
|
||||
await adminPool.query(
|
||||
`ALTER DATABASE "${databaseName}" RENAME TO "${databaseName}_deleted_${timestamp}"`
|
||||
);
|
||||
} finally {
|
||||
await adminPool.end();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate (close and remove) a specific tenant's pool.
|
||||
*/
|
||||
invalidatePool(tenantId: string): void {
|
||||
const entry = this.pools.get(tenantId);
|
||||
if (entry) {
|
||||
entry.pool.end().catch(() => {});
|
||||
this.pools.delete(tenantId);
|
||||
}
|
||||
this.migratedPools.delete(tenantId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove idle pools (not accessed in last 5 minutes).
|
||||
*/
|
||||
private cleanupIdlePools(): void {
|
||||
const now = Date.now();
|
||||
const maxIdle = 5 * 60 * 1000;
|
||||
|
||||
for (const [tenantId, entry] of this.pools.entries()) {
|
||||
if (now - entry.lastAccess.getTime() > maxIdle) {
|
||||
entry.pool.end().catch((err) =>
|
||||
console.error(`[TenantDB] Error closing idle pool for ${tenantId}:`, err.message)
|
||||
);
|
||||
this.pools.delete(tenantId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Graceful shutdown: close all pools.
|
||||
*/
|
||||
async shutdown(): Promise<void> {
|
||||
if (this.cleanupInterval) {
|
||||
clearInterval(this.cleanupInterval);
|
||||
}
|
||||
|
||||
const closePromises = Array.from(this.pools.values()).map((entry) =>
|
||||
entry.pool.end()
|
||||
);
|
||||
await Promise.all(closePromises);
|
||||
this.pools.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stats about active pools.
|
||||
*/
|
||||
getStats(): { activePools: number; tenantIds: string[] } {
|
||||
return {
|
||||
activePools: this.pools.size,
|
||||
tenantIds: Array.from(this.pools.keys()),
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const tenantDb = new TenantConnectionManager();
|
||||
89
apps/api/src/config/env.ts
Normal file
89
apps/api/src/config/env.ts
Normal 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
Reference in New Issue
Block a user