Compare commits

..

72 Commits

Author SHA1 Message Date
Horux Dev
a1727321c3 fix: vendedor accede a invitaciones trial (no invitar cliente) 2026-06-22 23:06:39 +00:00
Horux Dev
cc002adbd2 fix: evita logout al cambiar de tenant (race condition refresh token) 2026-06-22 21:47:13 +00:00
Horux Dev
3c7758a599 feat: drill-down en pestaña nueva, rol Vendedor y scripts demo 2026-06-22 20:45:42 +00:00
Horux Dev
7df27ce66d chore: catálogo obligaciones, cierre automático, fixes SAT y facturación
- Catálogo de obligaciones fiscales expandido a 30 entradas con campo requierePago.
- Soporte de frecuencia cuatrimestral en obligaciones y declaraciones.
- Automatización de cierre de obligaciones fiscales desde Documentos › Declaraciones.
- Nuevas tablas obligacion_evidencias, obligacion_periodos estados y declaracion_obligaciones.
- Nuevo servicio obligacion-evidencias.service.ts y endpoints REST.
- Refactor de declaraciones.service.ts para vincular obligaciones y crear evidencias.
- Notificaciones por email para evidencias de obligaciones.
- Adjuntar PDFs en correo de declaración subida.
- Fix drill-down de CFDIs: carga completa al visualizar.
- Fix sincronización SAT: tipos P/N, UUID case-insensitive, no reutilizar requestId.
- Fix suscripciones pending en /configuracion/planes-despacho.
- Fix sugerencias de Clave Producto SAT: importar catálogo y robustecer autocomplete.
- Quitar toggle manual de completado en Configuración › Obligaciones fiscales › Tareas.
- Scripts de soporte para Demo Ventas y utilerías (change-user-email, resend-welcome, import-clave-prod-serv).
- Documentación de cambios en docs/CAMBIOS-2026-05-04.md.
2026-06-22 04:53:59 +00:00
Horux Dev
b217342a96 feat(notificaciones): configuración de notificaciones por rol
- Nueva tabla tenant notification_role_preferences para guardar (email_type, role, enabled).
- Migración 051 aplicada a todos los tenants.
- Backend expone endpoint /notificaciones con matriz de preferencias por rol.
- Filtrado por rol en documento_subido, weekly_update, subscription_expiring,
  alertas_nuevas y recordatorio_proximo.
- Frontend rediseñado como tabla notificación × rol con toggles inmediatos.
2026-06-17 00:04:37 +00:00
Horux Dev
8a1fbceb38 fix(notificaciones): quitar badge Próximamente de notificaciones ya existentes
- weekly_update y subscription_expiring están implementadas; se marcan como active.
- Se indica con badge 'A nivel despacho' cuando una notificación no se puede
  desactivar por contribuyente y se deshabilita el toggle.
2026-06-16 22:55:53 +00:00
Horux Dev
3f3253d41b fix(pagos): permitir pagar plan actual trial_expired y soportar planes >$10k via Preference
- Expone subscription trial_expired en /despachos/me/plan e incluye planPrice.
- Para Business Control/Enterprise (>$10k) genera pago anual único con MP Preference
  en lugar de preapproval recurrente; el webhook activa 1 año de suscripción.
- Muestra CTA de pago en UI cuando la suscripción está trial/trial_expired.
- Agrega campo mp_preference_id a subscriptions y mejora mensajes de error MP.
2026-06-16 22:37:11 +00:00
Horux Dev
63908f9e9d feat(sat): agregar cron de recuperación diaria a las 10:00 AM
- Revisa si el sync diario falló o si hay CFDIs vigentes sin xml_original.
- Si detecta facturas incompletas, lanza un sync initial con rango extendido
  (desde un mes antes de la factura incompleta más antigua hasta ayer).
- Corre secuencialmente por contribuyente para no saturar al SAT.
- Incluye soporte para tenants legacy sin contribuyentes.
2026-06-14 04:07:11 +00:00
Horux Dev
ed6cfed312 feat(dashboard): utilidad neta ajustada por notas de crédito
- La utilidad del dashboard ahora descuenta NCs emitidas de ingresos y NCs recibidas de gastos.
- El margen se calcula sobre ingresos netos.
- Solo afecta la UI del dashboard; no modifica el backend ni otros reportes.
2026-06-13 21:04:25 +00:00
Horux Dev
ab6b76fcb8 ui(dashboard): reordenar scorecards de notas de crédito
- NCs Emitidas ahora aparece después de Ingresos del Mes.
- NCs Recibidas ahora aparece después de Gastos del Mes.
2026-06-13 20:54:40 +00:00
Horux Dev
b52ff875be feat(dashboard): agregar scorecards de notas de crédito emitidas y recibidas
- Extiende KpiData con ncsEmitidas, ncsEmitidasPorRegimen, ncsRecibidas y ncsRecibidasPorRegimen.
- En getKpis se reutilizan calcularNcsEmitidasPorRegimen y calcularNcsRecibidasPorRegimen en paralelo.
- En el dashboard se agregan dos KpiCard y su desglose por régimen.
2026-06-13 20:46:57 +00:00
Horux Dev
66d68c652c Revert "feat(ui): make dashboard responsive for iPhone and mobile devices"
This reverts commit d3b326e.

The deployment caused reports of blank screens and 400 errors. Reverting to restore stable state while investigating root cause.
2026-06-13 20:16:04 +00:00
Horux Dev
d3b326e78c feat(ui): make dashboard responsive for iPhone and mobile devices
- Add Sheet primitive component for mobile drawers
- Add MobileNav with hamburger menu for dashboard layout
- Hide desktop sidebars on mobile; show mobile header
- Make dashboard header responsive with stacked layout on small screens
- Hide selector text on mobile, show icons only
- Convert fixed-width filters to responsive widths (CFDI, Clientes, Admin, Documentos, Alertas)
- Cap dialog widths to 95vw on mobile (CFDI viewer, Documentos, Reportes, Contribuyentes, Facturación)
- Make calendar grid smaller and use single-letter weekdays on mobile
- Update viewport to include viewport-fit=cover for Samsung safe areas
2026-06-13 19:55:06 +00:00
Horux Dev
b1eaf41681 fix(sat, payments, admin): multiple production fixes
- sat sweep-stale-jobs: increase initial/custom sync threshold 8h→24h to prevent watchdog killing long historical syncs
- sat-client: fix formatDateForSat same-day rejection by auto-adjusting fechaFin
- sat-sync job: check fiel_contribuyente in addition to fiel_credentials for cron eligibility
- database: extend pool idle cleanup from 5min to 12h to prevent pool closure during long syncs
- webhook controller: auto-extend currentPeriodEnd on recurring MercadoPago payments
- invoicing service: auto-send FacturAPI invoice by email after creation
- admin-clientes: fix no-renovaciones detection to include expired trials and deleted subscriptions
2026-06-10 18:11:47 +00:00
Horux Dev
bd7e499ab7 fix(csf): retry con backoff, delays entre tenants, timeouts aumentados 2026-06-01 23:43:43 +00:00
Horux Dev
44144ebf9d fix(contribuyente-selector): limpiar selección inválida de localStorage 2026-06-01 20:13:36 +00:00
Horux Dev
314a74982c fix(regimen): fallback a tenant/contribuyentes cuando un contribuyente no tiene regimen_fiscal 2026-06-01 20:07:59 +00:00
Horux Dev
76d3f00f29 debug(alertas): logging en generador y endpoint /automaticas; wrap cada alerta en try/catch 2026-06-01 19:59:57 +00:00
Horux Dev
214410d2fb fix(alertas): combinar regímenes de contribuyentes cuando no hay config a nivel tenant 2026-06-01 17:55:01 +00:00
Horux Dev
199922272f fix(sidebar): mostrar Usuarios para supervisor y auxiliar 2026-05-29 22:06:01 +00:00
Horux Dev
6e54efe5e4 feat(usuarios): supervisor puede invitar usuarios cliente
- Backend inviteUsuario: permite owner, cfo y supervisor
- Backend valida que supervisor solo pueda invitar rol cliente
- Backend addClienteAcceso: supervisor solo puede asignar contribuyentes
  que tenga visibles (getEntidadesVisibles)
- Frontend: supervisor ve botón Invitar Usuario y solo puede seleccionar
  rol Cliente en el dropdown
2026-05-29 21:32:12 +00:00
Horux Dev
5dd53cebac chore(usuarios): limpiar debug hardcodeado de supervisorNombre 2026-05-29 19:27:59 +00:00
Horux Dev
0de0df9357 fix(usuarios): mostrar nombre del supervisor en dropdown de forma robusta
- Backend: getSupervisor devuelve supervisorNombre desde Prisma
- Frontend: usa SelectTrigger con renderizado manual del label seleccionado
  en lugar de depender de SelectValue, que no siempre encontraba el texto
  del SelectItem cuando el supervisor no estaba en la lista de carteras
2026-05-29 19:03:36 +00:00
Horux Dev
20fb8ea2db debug(usuarios): agregar console.log para diagnosticar supervisorNombre 2026-05-29 18:10:33 +00:00
Horux Dev
8c9a7b73dc fix(usuarios): agregar import faltante de prisma en getSupervisor 2026-05-29 17:43:13 +00:00
Horux Dev
910c50d870 fix(usuarios): mostrar nombre del supervisor al editar auxiliar
- Backend getSupervisor ahora devuelve supervisorNombre buscando en Prisma
- Frontend usa supervisorNombre para mostrar en Select cuando el supervisor
  no está en la lista de carteras/supervisores
2026-05-29 17:24:18 +00:00
Horux Dev
2f49fdc9b7 fix(contribuyentes): agregar supervisorNombre al tipo Contribuyente 2026-05-29 17:12:58 +00:00
Horux Dev
0439a84e6d feat(contribuyentes): mostrar nombre del supervisor en card 2026-05-29 16:57:04 +00:00
Horux Dev
0815269f1b fix(papeleria): mover useMemo antes del return condicional para evitar error #310 2026-05-29 16:42:37 +00:00
Horux Dev
9b535354fb feat(papeleria): aprobación independiente por cliente
- Agrega migración 050 con columnas de aprobación de cliente
  (requiere_aprobacion_cliente, estado_cliente, aprobado_por_cliente, etc.)
- Backend: endpoints /aprobar-cliente y /rechazar-cliente con validación de permisos
- Backend: list/download permiten acceso a clientes filtrando por entidades visibles
- Backend: notificación por email a clientes cuando se les solicita aprobación
- Frontend: checkbox independiente para solicitar aprobación del cliente
- Frontend: badge de estado combinado (owner + cliente)
- Frontend: botones de aprobar/rechazar para clientes en su propio flujo
2026-05-29 00:36:33 +00:00
Horux Dev
e01422e443 fix(facturacion): filtrar searchConceptos y searchRfcs por contribuyenteId
- searchConceptos: agrega AND c.contribuyente_id =  cuando se recibe contribuyenteId
- searchRfcs: restringe el catálogo global de rfcs a aquellos que aparecen en CFDIs del contribuyente (como emisor o receptor)
- Usa parametrización dinámica (3800099{params.length}) para evitar errores de índice
2026-05-28 21:44:32 +00:00
Horux Dev
2208cee87f fix(impuestos): desactivar JIT en queries con subplans correlacionados
- Agrega helper withJitOff en impuestos.service.ts
- Ejecuta getResumenIva, getIvaMensual y readResumenIvaFromCache con SET LOCAL jit = off
- Evita compilación JIT de ~17s en queries con costo estimado alto

feat(contribuyentes): auto-asignar a cartera del supervisor

- Al crear contribuyente con supervisorUserId, se agrega automáticamente
  a todas las carteras top-level del supervisor

feat(permisos): restricciones de UI por rol en contribuyentes

- Oculta botón Add-ons para roles distintos de owner/cfo
- Oculta botón Eliminar contribuyente para no-owner
- Oculta botón Agregar RFC para auxiliar/visor/cliente/contador

feat(cfdi): ver CFDI desde conceptos y forma de pago en Excel

- Agrega botón Ver CFDI en cada fila de la tabla de Conceptos
- Agrega columna Forma de Pago en export Excel de CFDIs
- Agrega columna Forma de Pago en export individual de CFDI

chore(migraciones): índices GIN para relaciones de activos

- 048: índices btree parciales para activos
- 049: índices GIN para cfdis_relacionados y uuid_relacionado
2026-05-28 02:38:30 +00:00
Horux Dev
138e223361 fix(subnav): ocultar pestaña Contribuyentes en Despacho para no-owner/cfo
- La página /despachos/contribuyentes solo permite owner/cfo/platform_staff.
- La pestaña en el subnav ahora solo se muestra a esos roles, evitando que
  supervisor, contador, visor y auxiliar vean un link que lleva a mensaje
  de 'solo disponible para owner'.
2026-05-26 00:20:51 +00:00
Horux Dev
441ec20059 fix(despacho-stats): contar obligaciones y tareas pendientes correctamente
- Obligaciones: las obligaciones activas sin registro en obligacion_periodos
  para el periodo actual ahora se cuentan como pendientes (antes daban 0)
- Tareas: se materializan los periodos antes de contar para que las tareas
  sin registro previo aparezcan como pendientes
- Usa CTEs separadas para obligaciones y tareas evitando producto cartesiano
2026-05-25 23:40:17 +00:00
Horux Dev
929aeec641 feat(declaraciones): supervisor puede crear declaraciones y extras
- Backend: agrega 'supervisor' a ROLES_UPLOAD en documentos.controller.ts
- Frontend: agrega 'supervisor' a ROLES_UPLOAD y ROLES_UPLOAD_EXTRA en
  documentos/page.tsx para habilitar botones de subir declaración,
  comprobante de pago, eliminar y subir PDFs extra
2026-05-25 21:49:01 +00:00
Horux Dev
4a885de520 feat(configuracion): supervisor puede ver regimenes y domicilio fiscal
- Extiende la condición de visibilidad de Regímenes Fiscales, Domicilio
  Fiscal y Bancos para incluir al rol supervisor
2026-05-25 19:25:07 +00:00
Horux Dev
c84ad6c4db feat(sidebar): Configuracion visible para auxiliar y cliente
- Sidebars/topnav: agrega 'auxiliar' y 'cliente' a la opción Configuracion
- /configuracion/page.tsx: auxiliar y cliente solo ven Información de Usuario,
  Información de Empresa y Seguridad (cambio de contraseña). Todo lo demás
  (FIEL, Obligaciones, Notificaciones, Facturación, CSD) queda restringido
  a owner/cfo/supervisor
2026-05-25 16:57:10 +00:00
Horux Dev
acd7de76d9 feat(sidebar): mostrar Configuracion a supervisor
- Extiende roles de la opción Configuracion en sidebar, sidebar-compact,
  sidebar-floating y topnav
2026-05-25 16:46:09 +00:00
Horux Dev
9c4a2343f5 feat(auth): supervisor puede configurar FIEL, CSD y Obligaciones
- Backend: agrega 'supervisor' a authorize() de rutas:
  - POST/DELETE /contribuyentes/:id/fiel
  - POST /contribuyentes/:id/facturapi/csd
  - POST/DELETE /contribuyentes/:id/obligaciones/*
- Frontend: muestra tarjeta 'Obligaciones Fiscales' en /configuracion
  para rol supervisor
2026-05-25 16:39:31 +00:00
Horux Dev
1d828adc27 feat(contribuyentes): mostrar contador de RFCs disponibles del plan
- Agrega contador 'X de Y RFCs' debajo del título de la página
- Usa DESPACHO_PLANS desde @horux/shared para obtener maxRfcs del plan actual
- Durante trial muestra 'X de 5 RFCs'
- Planes ilimitados muestran solo 'X RFCs'
2026-05-25 16:20:37 +00:00
Horux Dev
4c7ab4fd35 feat(sidebar): mostrar Contribuyentes a supervisor, contador y auxiliar
- Extiende roles de la opción Contribuyentes en sidebar, sidebar-compact,
  sidebar-floating y topnav
2026-05-25 15:52:46 +00:00
Horux Dev
0fa2c3c90f feat(contribuyentes): permitir a supervisor crear contribuyentes
- Agrega 'supervisor' al authorize() de POST /contribuyentes
2026-05-25 15:22:23 +00:00
Horux Dev
cbefaa2bf7 refactor(declaraciones): renombrar SUELDOS→ISN, agregar ISH
- Cambia la opción 'SUELDOS' por 'ISN' (Impuesto Sobre Nómina)
- Agrega nueva opción 'ISH' (Impuesto Sobre Hospedaje)
- ISH no cierra alertas ni obligaciones (aún no hay flujo definido)
- ISN mantiene keywords de sueldos/salarios/nómina + agrega 'isn'
- Migración 047: actualiza declaraciones históricas SUELDOS→ISN en BD
2026-05-25 02:35:16 +00:00
Horux Dev
e35eae2a72 refactor(cfdi): descarga masiva de XMLs por filtros en lugar de checkboxes
- Backend: POST /cfdi/download-xmls acepta CfdiFilters, usa getXmlsByFilters con LIMIT 1000
- Frontend: eliminados checkboxes y estado selectedIds; botón Descargar XMLs usa filtros activos
- Si >1000 resultados, muestra confirm() de advertencia pero permite proceder
- Agregada documentación técnica y changelog
2026-05-24 21:40:08 +00:00
Horux Dev
5c940847af feat(cfdi): descarga masiva de XMLs como ZIP, limite 1,000 2026-05-24 21:19:56 +00:00
Horux Dev
80e2c099d9 style(conciliacion): aumentar tamano de fuente en tabla Conciliadas 2026-05-24 19:54:46 +00:00
Horux Dev
70f94ce0f2 style(conciliacion): aumentar tamano de fuente en tabla Por conciliar 2026-05-24 19:44:55 +00:00
Horux Dev
a24947187a feat(conciliacion): columnas de regimen en tabla Por conciliar segun tab 2026-05-24 19:39:21 +00:00
Horux Dev
c65e3455e6 fix(conciliacion): headers visibles aun sin resultados en filtros 2026-05-24 19:28:45 +00:00
Horux Dev
31be887882 feat(conciliacion): metricas I+P-E para montos conciliado y pendiente 2026-05-24 02:17:06 +00:00
Horux Dev
3eeec3c60e fix(conciliacion): celdas dinamicas RFC/Nombre aplicadas a tabla conciliadas 2026-05-24 01:28:07 +00:00
Horux Dev
face71ef5d feat(conciliacion): columnas RFC/Nombre dinamicas segun tab en conciliadas 2026-05-24 01:11:07 +00:00
Horux Dev
a727c1b069 feat(conciliacion): filtro autocomplete en columna Banco de conciliadas 2026-05-24 01:05:27 +00:00
Horux Dev
918d84f2d2 feat(conciliacion): filtros de columna con sugerencias autocomplete
- Agregar prop suggestions a FilterHeader con dropdown de opciones
- Calcular valores unicos de rfc/nombre emisor/receptor desde los
  CFDIs cargados en memoria
- Filtrar sugerencias segun texto escrito (max 8 resultados)
- Al seleccionar una sugerencia se aplica el filtro y cierra el popover
2026-05-24 00:55:08 +00:00
Horux Dev
a30060050b fix(conciliacion): filtros de columna se atascaban al escribir
- Mover FilterHeader fuera de ConciliacionPage para evitar
  desmonte/remonte en cada render (causaba perdida de foco)
- Agregar debounce de 300ms al input de filtro para reducir
  re-renders mientras el usuario escribe
2026-05-24 00:37:35 +00:00
Horux Dev
8f420711ae docs: sesion 2026-05-23 asignaciones tareas admin ui 2026-05-23 23:42:25 +00:00
Horux Dev
be96ecc324 feat: invitaciones trial como pestaña en admin usuarios + sidebar
- Quitado Invitaciones Trial del sidebar (4 layouts)
- Agregado tab Invitaciones Trial dentro de /admin/usuarios
- Componente reutilizable invitaciones-trial-tab.tsx
- Agregada nueva opcion Tareas en el sidebar principal
2026-05-23 23:41:58 +00:00
Horux Dev
bba000d308 feat: pagina /tareas + quitar completar obligaciones fiscales
- Nueva pagina /tareas para ver y marcar tareas operativas
- Endpoint GET /tareas/mis-tareas con periodo actual
- Quitado boton de marcar completada de obligaciones fiscales en /pendientes
2026-05-23 23:41:28 +00:00
Horux Dev
e8b0733304 feat: seguimiento auxiliares UI con tabs Asignadas/Sin asignar
- Componente seguimiento-auxiliares.tsx con tabs Asignadas/Sin asignar
- Tabs internos Obligaciones/Tareas en cada vista
- API client y hooks para asignaciones
- Fix: invalidar query sin-asignar al asignar/desasignar
2026-05-23 23:40:39 +00:00
Horux Dev
f43cb165c6 feat: asignaciones obligaciones/tareas + fixes backend
- Migracion 046: tablas obligacion_asignaciones y tarea_asignaciones
- Servicio y controller de asignaciones (CRUD + listados)
- Fix: enviar correo welcome al invitar usuario nuevo
- Fix: quitar JOIN users de queries tenant (usar Prisma en BD central)
- Fix: req.params.obligacionId correcto en asignaciones controller
- Fix: orden rutas estaticas antes de dinamicas en cartera.routes
- Fix: owner/cfo ven todas las asignaciones en getAsignacionesPorSupervisor
- Fix: validar que entidad pertenezca a cartera padre en subcartera
- Nuevo endpoint GET /carteras/asignaciones/sin-asignar
- Nuevo endpoint GET /tareas/mis-tareas
2026-05-23 23:40:12 +00:00
Horux Dev
0c7580aa44 docs: sesión 2026-05-22 — personalización CSD, fecha emisión, precio sin IVA, cuenta predial 2026-05-23 18:18:39 +00:00
Horux Dev
a91a2f415d feat(facturacion): cuenta predial para régimen 606 (arrendamiento)
- Frontend: muestra input 'No. Cuenta Predial' en sección 'Datos del Inmueble'
  cuando el régimen del emisor es 606 (Arrendamiento), antes de Conceptos
- Frontend: incluye cuentaPredial en payload; se resetea al cambiar contribuyente
- Backend: pasa property_tax_account a nivel de cada item en Facturapi
  para facturapi.service.ts y contribuyente-facturapi.service.ts
- Build y deploy exitosos
2026-05-22 23:20:36 +00:00
Horux Dev
0c8ae05919 feat(facturacion): precio unitario sin IVA en conceptos
- Cambia label de 'Precio Unitario (IVA incluido)' a 'Precio Unitario (sin IVA)'
- Elimina división interna price/(1+iva) en calcConcepto; ahora price es la base
- Cambia taxIncluded: true → false en payload enviado a backend
- Backend: tax_included default false en facturapi.service.ts y
  contribuyente-facturapi.service.ts
- Build y deploy exitosos
2026-05-22 22:23:38 +00:00
Horux Dev
1bde570035 feat(facturacion): fecha de emisión personalizable para I, E, T
- Frontend: input datetime-local visible solo para tipos I, E, T
  (no P). Default al día actual a las 12:00. Se resetea al cambiar tipo.
- Frontend: validación en handleSubmit: fecha ≤ ahora y ≥ ahora-72h
- Backend controller: validación idéntica antes de consumir timbre
- Backend servicios: pasa campo 'date' al payload de Facturapi
  cuando viene 'fechaEmision' en el body
- Build y deploy exitosos
2026-05-22 20:11:03 +00:00
Horux Dev
5ba31b7291 fix: personalización logo/color por contribuyente en vez de tenant
- Agrega getCustomizationContribuyente, uploadLogoContribuyente,
  updateColorContribuyente en contribuyente-facturapi.service.ts
- Agrega controllers per-contribuyente en facturacion.controller.ts
- Agrega rutas GET/POST/PUT /contribuyentes/:id/facturapi/customization|logo|color
- Modifica CustomizationSection para recibir contribuyenteId, usar endpoints
  per-contribuyente, y corrige useState mal aplicado a useEffect
- Backend y frontend buildeados y deployados
2026-05-22 18:20:09 +00:00
Horux Dev
46846200da feat(sat): factura global + fecha_efectiva, fallback tenant-contribuyente, fix anio_global typo
Factura Global & fecha_efectiva:
- Migracion 045_factura_global.sql: periodicidad, meses_global, año_global, fecha_efectiva
- sat-parser.service.ts: extrae InformacionGlobal del XML
- sat.service.ts: calcFechaEfectiva con soporte bimestral (periodicidad 05)
- metricas-compute, dashboard, impuestos, cfdi, export, conciliacion, alertas:
  reemplaza fecha_emision-1h por COALESCE(fecha_efectiva, fecha_emision-1h)
- Script recalc-metricas.ts para recalculo manual

Fallback datos fiscales tenant → contribuyente:
- contribuyente.service.ts: fetchTenantFiscalData + mergeContribuyenteWithTenant
  rellena regimenFiscal, codigoPostal y domicilio cuando el contribuyente
  tiene el mismo RFC que el tenant y sus campos estan vacios
- contribuyente.controller.ts y contribuyente-config.controller.ts:
  pasan req.user!.tenantId al servicio

Fix critico SAT sync:
- sat.service.ts: anio_global → año_global en INSERT/UPDATE de CFDIs
  (la migracion creo 'año_global' con tilde; el codigo usaba 'anio_global',
   causando fallo en 100% de inserciones de CFDI)
- determineChunkMonths: salta sondeo si existe job previo con requestIds
- MAX_POLL_ATTEMPTS: 45 → 500 (~8h) para syncs iniciales grandes

Docs:
- docs/sessions/2026-05-22-factura-global-contribuyente-fallback.md
2026-05-22 15:52:10 +00:00
Horux Dev
ba6004ebd6 docs: sesión 2026-05-20 — saldo PPD, seguridad cancelación, sync Alcaraz Salazar 2026-05-20 05:19:50 +00:00
Horux Dev
b5e307e142 fix(facturacion): saldo pendiente PPD + seguridad cancelación multi-contribuyente
- Inicializar saldo_pendiente_mxn al emitir facturas I/PPD vía Facturapi
  (antes quedaba NULL y no aparecían en complemento de pago)
- Validar ownership en cancelación: backend rechaza 403 si el caller
  intenta cancelar una factura de otro contribuyente
- Frontend: ocultar botón cancelar si no se es el emisor de la factura
- Frontend: enviar contribuyenteId en la petición de cancelación
2026-05-20 05:18:34 +00:00
Horux Dev
98e982c260 fix(documentos): capturar timeout SAT en consulta CSF manual
El controller ahora devuelve 504 (gateway timeout) con mensaje claro
en vez de 500 genérico cuando el scraper del SAT excede el tiempo.

Anteriormente solo capturaba errores con 'FIEL' en el mensaje;
los timeouts de page.waitForURL se escapaban como 500.
2026-05-19 21:45:20 +00:00
Horux Dev
8f796b2403 fix(api): evitar 500 al crear tenant con email existente + rate-limit trust proxy
- createTenant ahora reusa User si el email ya existe globalmente
  (hace upsert de membership en vez de crear user duplicado)
- Arregla error de express-rate-limit con X-Forwarded-For:
  app.set('trust proxy', 1) para que funcione detrás de Cloudflare
- Tipos de email templates actualizados para tempPassword nullable
2026-05-19 21:42:57 +00:00
Horux Dev
d0174fed3e feat(cfdi-viewer): mostrar complemento de pago en facturas tipo P
Para CFDIs tipo P (Pago) el visor ahora muestra:
- Monto pagado
- Fecha de pago
- Número de parcialidad
- UUID relacionado (factura pagada)
- Saldo insoluto
- Impuestos del pago (ISR/IVA/IEPS retenciones y traslados)

Además se ocultan para tipo P:
- Tabla de conceptos (dummy)
- Bloque de totales tradicional (subtotal/IVA/total)
- Sección CFDIs relacionados (reemplazada por UUID pagado)

El complemento de pago se renderiza en una card verde destacada.
2026-05-19 16:15:41 +00:00
Horux Dev
0b704e0e27 feat(admin/usuarios): agregar usuario globalmente desde admin
El admin global ahora puede crear usuarios directamente desde
/admin/usuarios sin depender de que un owner los invite.

Backend:
- Nuevo endpoint POST /usuarios/global (controller + service)
- Valida límite de usuarios del plan del tenant destino
- Si el email ya existe, agrega membership al tenant destino
- Si no existe, crea user con temp password + membership
- Schema Zod: email, nombre, role, tenantId, supervisorUserId?

Frontend:
- Botón 'Agregar Usuario' en /admin/usuarios
- Formulario con: nombre, email, rol, empresa
- Hook useCreateUsuarioGlobal con invalidación de queries
2026-05-17 14:32:45 +00:00
166 changed files with 10444 additions and 1347 deletions

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "subscriptions" ADD COLUMN "mp_preference_id" TEXT;

View File

@@ -358,6 +358,7 @@ model Subscription {
tenantId String @map("tenant_id") tenantId String @map("tenant_id")
plan Plan plan Plan
mpPreapprovalId String? @map("mp_preapproval_id") mpPreapprovalId String? @map("mp_preapproval_id")
mpPreferenceId String? @map("mp_preference_id")
status String @default("pending") status String @default("pending")
amount Decimal @db.Decimal(10, 2) amount Decimal @db.Decimal(10, 2)
frequency String @default("monthly") frequency String @default("monthly")

View File

@@ -0,0 +1,279 @@
/**
* Script: add-demo-cfdis.ts
*
* Agrega CFDIs sintéticos adicionales a los contribuyentes del tenant
* "Demo Ventas" (horux_demoventas). Los CFDIs se generan con UUIDs
* deterministas, por lo que el script es idempotente: volverlo a correr no
* duplica registros.
*
* Uso:
* cd apps/api && npx tsx scripts/add-demo-cfdis.ts
*
* Opciones via env:
* DEMO_CFDIS_POR_CONTRIBUYENTE=80 # default: 80
* DEMO_DIAS_ATRAS=540 # default: 540 (~18 meses)
*/
import { PrismaClient } from '@prisma/client';
import { Pool } from 'pg';
import { createHash } from 'crypto';
import { tenantDb } from '../src/config/database.ts';
import { markForInvalidation } from '../src/services/metricas.service.js';
const prisma = new PrismaClient();
const DEMO_RFC = 'DEMO2501019X2';
const CFDIS_POR_CONTRIBUYENTE = parseInt(process.env.DEMO_CFDIS_POR_CONTRIBUYENTE || '80', 10);
const DIAS_ATRAS = parseInt(process.env.DEMO_DIAS_ATRAS || '540', 10);
const CLIENTES = [
{ rfc: 'CLI123456AB1', nombre: 'Cliente Alfa SA' },
{ rfc: 'CLI123456AB2', nombre: 'Cliente Beta SA' },
{ rfc: 'CLI123456AB3', nombre: 'Cliente Gamma SA' },
{ rfc: 'CLI123456AB4', nombre: 'Cliente Delta SA' },
{ rfc: 'CLI123456AB5', nombre: 'Cliente Epsilon SA' },
{ rfc: 'CLI123456AB6', nombre: 'Cliente Zeta SA' },
{ rfc: 'CLI123456AB7', nombre: 'Cliente Eta SA' },
];
const PROVEEDORES = [
{ rfc: 'PRO123456AB1', nombre: 'Proveedor Materiales SA' },
{ rfc: 'PRO123456AB2', nombre: 'Proveedor Servicios SA' },
{ rfc: 'PRO123456AB3', nombre: 'Proveedor Logistica SA' },
{ rfc: 'PRO123456AB4', nombre: 'Proveedor Tecnologia SA' },
{ rfc: 'PRO123456AB5', nombre: 'Proveedor Papeleria SA' },
{ rfc: 'PRO123456AB6', nombre: 'Proveedor Telecom SA' },
{ rfc: 'PRO123456AB7', nombre: 'Proveedor Asesoria SA' },
];
const PRODUCTOS = [
{ clave: '84111506', descripcion: 'Servicio de consultoria', unidad: 'Servicio' },
{ clave: '43232408', descripcion: 'Licencia de software', unidad: 'Licencia' },
{ clave: '81141500', descripcion: 'Soporte tecnico', unidad: 'Servicio' },
{ clave: '81121700', descripcion: 'Desarrollo web', unidad: 'Servicio' },
{ clave: '86101500', descripcion: 'Capacitacion', unidad: 'Servicio' },
{ clave: '50151500', descripcion: 'Materiales de oficina', unidad: 'Pieza' },
{ clave: '80181600', descripcion: 'Publicidad', unidad: 'Servicio' },
{ clave: '81112200', descripcion: 'Diseno grafico', unidad: 'Servicio' },
{ clave: '72121000', descripcion: 'Renta de oficinas', unidad: 'Servicio' },
{ clave: '73101500', descripcion: 'Servicios de telecomunicaciones', unidad: 'Servicio' },
{ clave: '43231500', descripcion: 'Infraestructura en la nube', unidad: 'Servicio' },
{ clave: '81141800', descripcion: 'Mantenimiento de sistemas', unidad: 'Servicio' },
];
const FORMAS_PAGO = ['01', '02', '03', '04', '28', '99'];
function deterministicUuid(seed: string): string {
const hex = createHash('sha256').update(seed).digest('hex');
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
}
function round2(n: number): number {
return Math.round(n * 100) / 100;
}
function randomDateWithin(daysBack: number): Date {
// Sesgar hacia fechas recientes: random^2 produce valores pequenos con mayor probabilidad
const daysAgo = Math.floor(Math.pow(Math.random(), 2) * daysBack);
const fecha = new Date();
fecha.setDate(fecha.getDate() - daysAgo);
fecha.setHours(8 + Math.floor(Math.random() * 10), Math.floor(Math.random() * 60), 0, 0);
return fecha;
}
async function main() {
console.log(`🌱 Agregando ${CFDIS_POR_CONTRIBUYENTE} CFDIs adicionales por contribuyente en Demo Ventas...\n`);
const tenant = await prisma.tenant.findUnique({ where: { rfc: DEMO_RFC } });
if (!tenant) throw new Error(`Tenant ${DEMO_RFC} no encontrado`);
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
const { rows: contribuyentes } = await pool.query<{ entidad_id: string; rfc: string; nombre: string }>(`
SELECT c.entidad_id, c.rfc, eg.nombre
FROM contribuyentes c
JOIN entidades_gestionadas eg ON eg.id = c.entidad_id
ORDER BY c.rfc
`);
if (contribuyentes.length === 0) throw new Error('No hay contribuyentes demo');
let totalCreados = 0;
let totalExistentes = 0;
for (const c of contribuyentes) {
const { creados, existentes } = await agregarCfdisContribuyente(pool, c);
console.log(`${c.rfc}: ${creados} CFDIs creados, ${existentes} ya existian`);
totalCreados += creados;
totalExistentes += existentes;
}
console.log(`\n🎉 Total: ${totalCreados} CFDIs nuevos, ${totalExistentes} ya existian`);
}
async function agregarCfdisContribuyente(
pool: Pool,
contribuyente: { entidad_id: string; rfc: string; nombre: string },
): Promise<{ creados: number; existentes: number }> {
const client = await pool.connect();
const mesesAfectados = new Set<string>();
try {
await client.query('BEGIN');
// Asegurar RFCs de clientes/proveedores y el contribuyente mismo
const rfcs = new Map<string, number>();
for (const p of [...CLIENTES, ...PROVEEDORES]) {
const { rows: [r] } = await client.query<{ id: number }>(`
INSERT INTO rfcs (rfc, razon_social, regimen_fiscal)
VALUES ($1, $2, '601')
ON CONFLICT (rfc) DO UPDATE SET razon_social = EXCLUDED.razon_social
RETURNING id
`, [p.rfc, p.nombre]);
rfcs.set(p.rfc, r.id);
}
const { rows: [principal] } = await client.query<{ id: number }>(`
INSERT INTO rfcs (rfc, razon_social, regimen_fiscal)
VALUES ($1, $2, '601')
ON CONFLICT (rfc) DO UPDATE SET razon_social = EXCLUDED.razon_social
RETURNING id
`, [contribuyente.rfc, contribuyente.nombre]);
rfcs.set(contribuyente.rfc, principal.id);
let creados = 0;
let existentes = 0;
for (let i = 0; i < CFDIS_POR_CONTRIBUYENTE; i++) {
const esEmitido = i % 2 === 0;
const contraparte = esEmitido
? CLIENTES[i % CLIENTES.length]
: PROVEEDORES[i % PROVEEDORES.length];
// Distribucion sesgada: muchos CFDIs pequenos, pocos grandes
const raw = Math.random() * Math.random();
const subtotal = Math.floor(raw * 60000) + 1500;
const iva = round2(subtotal * 0.16);
const total = round2(subtotal + iva);
const fecha = randomDateWithin(DIAS_ATRAS);
const year = String(fecha.getFullYear());
const month = String(fecha.getMonth() + 1).padStart(2, '0');
const fechaStr = fecha.toISOString();
const metodoPago = Math.random() > 0.35 ? 'PUE' : 'PPD';
const formaPago = FORMAS_PAGO[i % FORMAS_PAGO.length];
const usoCfdi = esEmitido ? 'G03' : 'G01';
const tipo = esEmitido ? 'EMITIDO' : 'RECIBIDO';
const rfcEmisor = esEmitido ? contribuyente.rfc : contraparte.rfc;
const nombreEmisor = esEmitido ? contribuyente.nombre : contraparte.nombre;
const rfcReceptor = esEmitido ? contraparte.rfc : contribuyente.rfc;
const nombreReceptor = esEmitido ? contraparte.nombre : contribuyente.nombre;
const uuid = deterministicUuid(`${contribuyente.rfc}-add-demo-cfdis-${i}`);
// Idempotencia: si el UUID ya existe, lo contamos y saltamos
const { rows: duplicados } = await client.query(`SELECT 1 FROM cfdis WHERE lower(uuid) = lower($1) LIMIT 1`, [uuid]);
if (duplicados.length > 0) {
existentes++;
continue;
}
const { rows: [cfdi] } = await client.query<{ id: number }>(`
INSERT INTO cfdis (
year, month, type, uuid, serie, folio, status, fecha_emision,
rfc_emisor_id, rfc_emisor, nombre_emisor,
rfc_receptor_id, rfc_receptor, nombre_receptor,
subtotal, subtotal_mxn, descuento, descuento_mxn,
total, total_mxn, moneda, tipo_cambio, tipo_comprobante,
metodo_pago, forma_pago, uso_cfdi,
iva_traslado, iva_traslado_mxn,
regimen_fiscal_emisor, regimen_fiscal_receptor,
contribuyente_id, fecha_efectiva, meses_global, año_global
) 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,
$27, $28,
$29, $30,
$31, $32, $33, $34
) RETURNING id
`, [
year, month, tipo, uuid, 'DEMO', String(100000 + i),
'Vigente', fechaStr,
rfcs.get(rfcEmisor), rfcEmisor, nombreEmisor,
rfcs.get(rfcReceptor), rfcReceptor, nombreReceptor,
subtotal, subtotal, 0, 0,
total, total, 'MXN', 1, 'I',
metodoPago, formaPago, usoCfdi,
iva, iva,
'601', '601',
contribuyente.entidad_id, fechaStr, month, year,
]);
// Conceptos: de 1 a 3, repartiendo exactamente el subtotal
const numConceptos = Math.floor(Math.random() * 3) + 1;
let importeRestante = round2(subtotal);
for (let j = 0; j < numConceptos; j++) {
const prod = PRODUCTOS[(i + j) % PRODUCTOS.length];
const esUltimo = j === numConceptos - 1;
const cantidad = Math.floor(Math.random() * 5) + 1;
let importe: number;
if (esUltimo) {
importe = importeRestante;
} else {
const promedio = importeRestante / (numConceptos - j);
const factor = 0.7 + Math.random() * 0.6; // 70% - 130% del promedio
importe = round2(promedio * factor);
importe = Math.min(importe, importeRestante - 0.01);
}
importeRestante = round2(importeRestante - importe);
const valorUnitario = round2(importe / cantidad);
const ivaConcepto = round2(importe * 0.16);
await client.query(`
INSERT INTO cfdi_conceptos (
cfdi_id, clave_prod_serv, descripcion, cantidad, clave_unidad, unidad,
valor_unitario, valor_unitario_mxn, importe, importe_mxn,
iva_traslado, iva_traslado_mxn
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
`, [
cfdi.id, prod.clave, prod.descripcion, cantidad, 'E48', prod.unidad,
valorUnitario, valorUnitario, importe, importe,
ivaConcepto, ivaConcepto,
]);
}
creados++;
mesesAfectados.add(`${year}-${month}`);
}
// Marcar meses afectados para recomputo de metricas
for (const ym of mesesAfectados) {
const [anio, mes] = ym.split('-').map(Number);
await markForInvalidation(pool, contribuyente.entidad_id, anio, mes, 'add-demo-cfdis');
}
await client.query('COMMIT');
return { creados, existentes };
} catch (err) {
await client.query('ROLLBACK').catch(() => {});
throw err;
} finally {
client.release();
}
}
main()
.catch((e) => {
console.error('\n❌ Error:', e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
await tenantDb.shutdown();
});

View File

@@ -0,0 +1,259 @@
/**
* Script: add-demo-notas-credito.ts
*
* Agrega notas de crédito (NC) sintéticas a los contribuyentes del tenant
* "Demo Ventas" (horux_demoventas). Cada NC se relaciona con una factura
* existente (tipo_comprobante = 'I', metodo_pago = 'PUE') mediante
* cfdi_tipo_relacion = '01' y cfdis_relacionados = uuid de la factura origen.
*
* El script es idempotente: usa UUIDs deterministas, por lo que volverlo a
* correr no duplica registros.
*
* Uso:
* cd apps/api && npx tsx scripts/add-demo-notas-credito.ts
*
* Opciones via env:
* DEMO_NC_POR_CONTRIBUYENTE=4 # default: 4 (2 emitidas + 2 recibidas)
* DEMO_NC_DIAS_DESPUES=90 # default: 90 (max dias despues de la factura)
*/
import { PrismaClient } from '@prisma/client';
import { Pool, type PoolClient } from 'pg';
import { createHash } from 'crypto';
import { tenantDb } from '../src/config/database.ts';
import { markForInvalidation } from '../src/services/metricas.service.js';
const prisma = new PrismaClient();
const DEMO_RFC = 'DEMO2501019X2';
const NC_POR_CONTRIBUYENTE = parseInt(process.env.DEMO_NC_POR_CONTRIBUYENTE || '4', 10);
const MAX_DIAS_DESPUES = parseInt(process.env.DEMO_NC_DIAS_DESPUES || '90', 10);
function deterministicUuid(seed: string): string {
const hex = createHash('sha256').update(seed).digest('hex');
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
}
function round2(n: number): number {
return Math.round(n * 100) / 100;
}
function addDays(fecha: Date, dias: number): Date {
const r = new Date(fecha);
r.setDate(r.getDate() + dias);
r.setHours(8 + Math.floor(Math.random() * 10), Math.floor(Math.random() * 60), 0, 0);
return r;
}
interface FacturaOrigen {
id: number;
uuid: string;
total: number;
fecha_emision: Date;
type: 'EMITIDO' | 'RECIBIDO';
rfc_emisor_id: number;
rfc_emisor: string;
nombre_emisor: string;
rfc_receptor_id: number;
rfc_receptor: string;
nombre_receptor: string;
forma_pago: string;
uso_cfdi: string;
regimen_fiscal_emisor: string;
regimen_fiscal_receptor: string;
}
async function main() {
console.log(`🌱 Agregando ${NC_POR_CONTRIBUYENTE} notas de crédito por contribuyente en Demo Ventas...\n`);
const tenant = await prisma.tenant.findUnique({ where: { rfc: DEMO_RFC } });
if (!tenant) throw new Error(`Tenant ${DEMO_RFC} no encontrado`);
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
const { rows: contribuyentes } = await pool.query<{ entidad_id: string; rfc: string; nombre: string }>(`
SELECT c.entidad_id, c.rfc, eg.nombre
FROM contribuyentes c
JOIN entidades_gestionadas eg ON eg.id = c.entidad_id
ORDER BY c.rfc
`);
if (contribuyentes.length === 0) throw new Error('No hay contribuyentes demo');
let totalCreadas = 0;
let totalExistentes = 0;
for (const c of contribuyentes) {
const { creadas, existentes } = await agregarNcContribuyente(pool, c);
console.log(`${c.rfc}: ${creadas} NCs creadas, ${existentes} ya existian`);
totalCreadas += creadas;
totalExistentes += existentes;
}
console.log(`\n🎉 Total: ${totalCreadas} notas de crédito nuevas, ${totalExistentes} ya existian`);
}
async function agregarNcContribuyente(
pool: Pool,
contribuyente: { entidad_id: string; rfc: string; nombre: string },
): Promise<{ creadas: number; existentes: number }> {
const client = await pool.connect();
const mesesAfectados = new Set<string>();
try {
await client.query('BEGIN');
const mitad = Math.ceil(NC_POR_CONTRIBUYENTE / 2);
const facturasEmitidas = await obtenerFacturasPUE(client, contribuyente.entidad_id, 'EMITIDO', mitad);
const facturasRecibidas = await obtenerFacturasPUE(client, contribuyente.entidad_id, 'RECIBIDO', NC_POR_CONTRIBUYENTE - mitad);
let creadas = 0;
let existentes = 0;
const usadas = new Set<string>();
let idxEmitida = 0;
let idxRecibida = 0;
for (let i = 0; i < NC_POR_CONTRIBUYENTE; i++) {
const esEmitida = i % 2 === 0;
const origen = esEmitida
? facturasEmitidas[idxEmitida++ % facturasEmitidas.length]
: facturasRecibidas[idxRecibida++ % facturasRecibidas.length];
if (!origen || usadas.has(origen.uuid)) {
// Si no hay suficientes facturas distintas, saltar
continue;
}
usadas.add(origen.uuid);
// Monto de la NC: entre 10% y 40% del total de la factura origen
const porcentaje = 0.1 + Math.random() * 0.3;
const ncTotal = round2(origen.total * porcentaje);
const ncSubtotal = round2(ncTotal / 1.16);
const ncIva = round2(ncTotal - ncSubtotal);
// Fecha: entre 5 y MAX_DIAS_DESPUES dias despues de la factura origen, sin pasar de hoy
const diasDespues = 5 + Math.floor(Math.random() * (MAX_DIAS_DESPUES - 5));
let ncFecha = addDays(origen.fecha_emision, diasDespues);
const ahora = new Date();
if (ncFecha > ahora) ncFecha = ahora;
const year = String(ncFecha.getFullYear());
const month = String(ncFecha.getMonth() + 1).padStart(2, '0');
const fechaStr = ncFecha.toISOString();
const uuid = deterministicUuid(`${contribuyente.rfc}-demo-nc-${esEmitida ? 'E' : 'R'}-${i}`);
const { rows: duplicados } = await client.query(
`SELECT 1 FROM cfdis WHERE lower(uuid) = lower($1) LIMIT 1`,
[uuid],
);
if (duplicados.length > 0) {
existentes++;
continue;
}
const { rows: [nc] } = await client.query<{ id: number }>(`
INSERT INTO cfdis (
year, month, type, uuid, serie, folio, status, fecha_emision,
rfc_emisor_id, rfc_emisor, nombre_emisor,
rfc_receptor_id, rfc_receptor, nombre_receptor,
subtotal, subtotal_mxn, descuento, descuento_mxn,
total, total_mxn, moneda, tipo_cambio, tipo_comprobante,
metodo_pago, forma_pago, uso_cfdi,
iva_traslado, iva_traslado_mxn,
regimen_fiscal_emisor, regimen_fiscal_receptor,
contribuyente_id, fecha_efectiva, meses_global, año_global,
cfdi_tipo_relacion, cfdis_relacionados
) 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,
$27, $28,
$29, $30,
$31, $32, $33, $34,
$35, $36
) RETURNING id
`, [
year, month, esEmitida ? 'EMITIDO' : 'RECIBIDO', uuid, 'NC', String(200000 + i),
'Vigente', fechaStr,
origen.rfc_emisor_id, origen.rfc_emisor, origen.nombre_emisor,
origen.rfc_receptor_id, origen.rfc_receptor, origen.nombre_receptor,
ncSubtotal, ncSubtotal, 0, 0,
ncTotal, ncTotal, 'MXN', 1, 'E',
'PUE', origen.forma_pago, origen.uso_cfdi,
ncIva, ncIva,
origen.regimen_fiscal_emisor, origen.regimen_fiscal_receptor,
contribuyente.entidad_id, fechaStr, month, year,
'01', origen.uuid.toLowerCase(),
]);
await client.query(`
INSERT INTO cfdi_conceptos (
cfdi_id, clave_prod_serv, descripcion, cantidad, clave_unidad, unidad,
valor_unitario, valor_unitario_mxn, importe, importe_mxn,
iva_traslado, iva_traslado_mxn
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
`, [
nc.id, '84111506', 'Descuento por nota de credito', 1, 'E48', 'Servicio',
ncSubtotal, ncSubtotal, ncSubtotal, ncSubtotal,
ncIva, ncIva,
]);
creadas++;
mesesAfectados.add(`${year}-${month}`);
}
for (const ym of mesesAfectados) {
const [anio, mes] = ym.split('-').map(Number);
await markForInvalidation(pool, contribuyente.entidad_id, anio, mes, 'demo-nc');
}
await client.query('COMMIT');
return { creadas, existentes };
} catch (err) {
await client.query('ROLLBACK').catch(() => {});
throw err;
} finally {
client.release();
}
}
async function obtenerFacturasPUE(
client: PoolClient,
contribuyenteId: string,
type: 'EMITIDO' | 'RECIBIDO',
limite: number,
): Promise<FacturaOrigen[]> {
const { rows } = await client.query<FacturaOrigen>(`
SELECT
id, uuid, total_mxn AS total, fecha_emision, type,
rfc_emisor_id, rfc_emisor, nombre_emisor,
rfc_receptor_id, rfc_receptor, nombre_receptor,
forma_pago, uso_cfdi,
regimen_fiscal_emisor, regimen_fiscal_receptor
FROM cfdis
WHERE contribuyente_id = $1
AND type = $2
AND tipo_comprobante = 'I'
AND metodo_pago = 'PUE'
AND status = 'Vigente'
AND total_mxn > 5000
ORDER BY random()
LIMIT $3
`, [contribuyenteId, type, Math.max(limite * 3, 20)]);
// Mezclar y retornar hasta `limite`
const mezcladas = rows.sort(() => Math.random() - 0.5);
return mezcladas.slice(0, limite);
}
main()
.catch((e) => {
console.error('\n❌ Error:', e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
await tenantDb.shutdown();
});

View File

@@ -0,0 +1,75 @@
/**
* Script: change-user-email
*
* Cambia el correo de un usuario, resetea su contraseña a una temporal
* y reenvía el correo de bienvenida con las nuevas credenciales.
*
* Ejecución:
* cd apps/api && npx tsx scripts/change-user-email.ts
*/
import { PrismaClient } from '@prisma/client';
import bcrypt from 'bcryptjs';
import { randomBytes } from 'crypto';
import { emailService } from '../src/services/email/email.service.js';
const prisma = new PrismaClient();
const OLD_EMAIL = 'eduardo.corona@corpcyl.com';
const NEW_EMAIL = 'miguel.corona@corpcyl.com';
function generateTempPassword(length = 12): string {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789';
let result = '';
const bytes = randomBytes(length);
for (let i = 0; i < length; i++) {
result += chars[bytes[i] % chars.length];
}
return result + '!';
}
async function main() {
const user = await prisma.user.findUnique({ where: { email: OLD_EMAIL } });
if (!user) {
console.error(`❌ No existe un usuario con el correo ${OLD_EMAIL}`);
process.exit(1);
}
const existing = await prisma.user.findUnique({ where: { email: NEW_EMAIL } });
if (existing) {
console.error(`❌ Ya existe un usuario con el correo ${NEW_EMAIL}`);
process.exit(1);
}
const tempPassword = generateTempPassword();
const passwordHash = await bcrypt.hash(tempPassword, 12);
await prisma.user.update({
where: { id: user.id },
data: {
email: NEW_EMAIL,
passwordHash,
tokenVersion: { increment: 1 },
},
});
await emailService.sendWelcome(NEW_EMAIL, {
nombre: user.nombre,
email: NEW_EMAIL,
tempPassword,
});
console.log('✅ Correo actualizado:', OLD_EMAIL, '→', NEW_EMAIL);
console.log('✅ Contraseña temporal generada y enviada por correo');
console.log(' Nombre:', user.nombre);
console.log(' Email:', NEW_EMAIL);
console.log(' Contraseña temporal:', tempPassword);
}
main()
.catch((e) => {
console.error('\n❌ Error:', e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

View File

@@ -0,0 +1,457 @@
/**
* Script: create-demo-ventas
*
* Crea una cuenta demo completa para ventas:
* - Tenant "Demo Ventas SA de CV" (plan custom, sin cobro)
* - Usuario owner: demo@horuxfin.com / Demo12345!
* - Base de datos propia con datos ficticios de contabilidad
* - Contribuyente, clientes/proveedores, CFDIs, bancos, conciliaciones,
* obligaciones fiscales y cartera.
*
* Ejecución:
* cd apps/api && npx tsx scripts/create-demo-ventas.ts
*/
import { PrismaClient } from '@prisma/client';
import { Pool } from 'pg';
import bcrypt from 'bcryptjs';
import { randomUUID } from 'crypto';
import { tenantDb } from '../src/config/database.ts';
const prisma = new PrismaClient();
const DEMO = {
rfc: 'DEMO2501019X2',
nombre: 'Demo Ventas SA de CV',
email: 'demo@horuxfin.com',
password: 'Demo12345!',
databaseName: 'horux_demoventas',
codigoPostal: '01000',
};
const CLIENTES = [
{ rfc: 'CLI123456AB1', nombre: 'Cliente Alfa SA' },
{ rfc: 'CLI123456AB2', nombre: 'Cliente Beta SA' },
{ rfc: 'CLI123456AB3', nombre: 'Cliente Gamma SA' },
{ rfc: 'CLI123456AB4', nombre: 'Cliente Delta SA' },
{ rfc: 'CLI123456AB5', nombre: 'Cliente Epsilon SA' },
];
const PROVEEDORES = [
{ rfc: 'PRO123456AB1', nombre: 'Proveedor Materiales SA' },
{ rfc: 'PRO123456AB2', nombre: 'Proveedor Servicios SA' },
{ rfc: 'PRO123456AB3', nombre: 'Proveedor Logistica SA' },
{ rfc: 'PRO123456AB4', nombre: 'Proveedor Tecnologia SA' },
{ rfc: 'PRO123456AB5', nombre: 'Proveedor Papeleria SA' },
];
const PRODUCTOS = [
{ clave: '84111506', descripcion: 'Servicio de consultoria', unidad: 'Servicio' },
{ clave: '43232408', descripcion: 'Licencia de software', unidad: 'Licencia' },
{ clave: '81141500', descripcion: 'Soporte tecnico', unidad: 'Servicio' },
{ clave: '81121700', descripcion: 'Desarrollo web', unidad: 'Servicio' },
{ clave: '86101500', descripcion: 'Capacitacion', unidad: 'Servicio' },
{ clave: '50151500', descripcion: 'Materiales de oficina', unidad: 'Pieza' },
{ clave: '80181600', descripcion: 'Publicidad', unidad: 'Servicio' },
{ clave: '81112200', descripcion: 'Diseno grafico', unidad: 'Servicio' },
];
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),
};
}
async function main() {
console.log('🌱 Creando cuenta demo "Demo Ventas"...\n');
const ownerRole = await prisma.rol.findUnique({ where: { nombre: 'owner' } });
if (!ownerRole) throw new Error('Rol owner no encontrado en BD central');
// ============================================================
// 1. Tenant
// ============================================================
let tenant = await prisma.tenant.findUnique({ where: { rfc: DEMO.rfc } });
if (!tenant) {
tenant = await prisma.tenant.create({
data: {
nombre: DEMO.nombre,
rfc: DEMO.rfc,
plan: 'custom',
databaseName: DEMO.databaseName,
verticalProfile: 'CONTABLE',
dbMode: 'MANAGED',
dbSchemaVersion: 0,
codigoPostal: DEMO.codigoPostal,
active: true,
},
});
console.log('✅ Tenant creado:', tenant.nombre, `(${tenant.rfc})`);
} else {
await prisma.tenant.update({
where: { id: tenant.id },
data: { plan: 'custom', active: true, verticalProfile: 'CONTABLE' },
});
console.log('✅ Tenant actualizado:', tenant.nombre, `(${tenant.rfc})`);
}
// ============================================================
// 2. Usuario owner
// ============================================================
let user = await prisma.user.findUnique({ where: { email: DEMO.email } });
const passwordHash = await bcrypt.hash(DEMO.password, 12);
if (!user) {
user = await prisma.user.create({
data: {
email: DEMO.email,
passwordHash,
nombre: 'Usuario Demo',
lastTenantId: tenant.id,
},
});
console.log('✅ Usuario creado:', user.email);
} else {
user = await prisma.user.update({
where: { id: user.id },
data: { passwordHash, lastTenantId: tenant.id },
});
console.log('✅ Usuario actualizado:', user.email);
}
// ============================================================
// 3. Membership
// ============================================================
await prisma.tenantMembership.upsert({
where: { userId_tenantId: { userId: user.id, tenantId: tenant.id } },
update: { rolId: ownerRole.id, isOwner: true, active: true },
create: {
userId: user.id,
tenantId: tenant.id,
rolId: ownerRole.id,
isOwner: true,
active: true,
},
});
console.log('✅ Membership owner asignada');
// ============================================================
// 4. Suscripción custom gratis/ilimitada (status authorized)
// ============================================================
const now = new Date();
const periodEnd = new Date(now.getTime() + 365 * 24 * 60 * 60 * 1000);
const existingSub = await prisma.subscription.findFirst({
where: { tenantId: tenant.id },
orderBy: { createdAt: 'desc' },
});
if (!existingSub) {
await prisma.subscription.create({
data: {
tenantId: tenant.id,
plan: 'custom',
status: 'authorized',
amount: 0,
frequency: 'monthly',
currentPeriodStart: now,
currentPeriodEnd: periodEnd,
},
});
} else {
await prisma.subscription.update({
where: { id: existingSub.id },
data: {
plan: 'custom',
status: 'authorized',
amount: 0,
currentPeriodStart: now,
currentPeriodEnd: periodEnd,
pendingPlan: null,
pendingFrequency: null,
pendingEffectiveAt: null,
upgradePreferenceId: null,
upgradeTargetPlan: null,
upgradeTargetAmount: null,
},
});
}
console.log('✅ Suscripción custom activa (gratis)');
// ============================================================
// 5. Régimen fiscal activo del tenant
// ============================================================
const regimen = await prisma.regimen.findUnique({ where: { clave: '601' } });
if (regimen) {
await prisma.tenantRegimenActivo.upsert({
where: { tenantId_regimenId: { tenantId: tenant.id, regimenId: regimen.id } },
update: {},
create: { tenantId: tenant.id, regimenId: regimen.id },
});
console.log('✅ Régimen 601 activado para el tenant');
}
// ============================================================
// 6. Base de datos del tenant
// ============================================================
await tenantDb.provisionDatabase(DEMO.rfc, DEMO.databaseName);
const pool = await tenantDb.getPool(tenant.id, DEMO.databaseName);
console.log('✅ Base de datos del tenant provisionada:', DEMO.databaseName);
// ============================================================
// 7. Datos ficticios en BD del tenant
// ============================================================
await seedTenantData(pool, tenant.id, user.id);
console.log('\n🎉 Demo Ventas lista');
console.log(' Login:', DEMO.email, '/', DEMO.password);
console.log(' Tenant:', DEMO.nombre, `(${DEMO.rfc})`);
console.log(' BD:', DEMO.databaseName);
}
async function seedTenantData(pool: Pool, tenantId: string, ownerId: string) {
const client = await pool.connect();
try {
await client.query('BEGIN');
// Contribuyente principal
const { rows: [entidad] } = await client.query<{ id: string }>(`
INSERT INTO entidades_gestionadas (tipo, nombre, identificador, supervisor_user_id)
VALUES ('CONTRIBUYENTE', $1, $2, $3)
ON CONFLICT DO NOTHING
RETURNING id
`, [DEMO.nombre, DEMO.rfc, ownerId]);
let contribuyenteId: string;
if (entidad) {
contribuyenteId = entidad.id;
await client.query(`
INSERT INTO contribuyentes (entidad_id, rfc, regimen_fiscal, codigo_postal)
VALUES ($1, $2, $3, $4)
ON CONFLICT (entidad_id) DO NOTHING
`, [contribuyenteId, DEMO.rfc, '601', DEMO.codigoPostal]);
} else {
const { rows: [existing] } = await client.query<{ id: string }>(`
SELECT e.id FROM entidades_gestionadas e
JOIN contribuyentes c ON c.entidad_id = e.id
WHERE e.identificador = $1
`, [DEMO.rfc]);
contribuyenteId = existing.id;
}
console.log('✅ Contribuyente principal creado:', DEMO.rfc);
// RFCs de clientes y proveedores
const rfcs = new Map<string, number>();
for (const c of [...CLIENTES, ...PROVEEDORES]) {
const { rows: [r] } = await client.query<{ id: number }>(`
INSERT INTO rfcs (rfc, razon_social, regimen_fiscal)
VALUES ($1, $2, $3)
ON CONFLICT (rfc) DO UPDATE SET razon_social = EXCLUDED.razon_social
RETURNING id
`, [c.rfc, c.nombre, c.rfc.startsWith('CLI') ? '601' : '601']);
rfcs.set(c.rfc, r.id);
}
// RFC del contribuyente principal
const { rows: [rfcPrincipal] } = await client.query<{ id: number }>(`
INSERT INTO rfcs (rfc, razon_social, regimen_fiscal)
VALUES ($1, $2, $3)
ON CONFLICT (rfc) DO UPDATE SET razon_social = EXCLUDED.razon_social
RETURNING id
`, [DEMO.rfc, DEMO.nombre, '601']);
rfcs.set(DEMO.rfc, rfcPrincipal.id);
// Bancos del contribuyente
const { rows: [banco1] } = await client.query<{ id: number }>(`
INSERT INTO bancos (banco, terminacion_cuenta, contribuyente_id)
VALUES ($1, $2, $3) RETURNING id
`, ['BBVA', '1234', contribuyenteId]);
const { rows: [banco2] } = await client.query<{ id: number }>(`
INSERT INTO bancos (banco, terminacion_cuenta, contribuyente_id)
VALUES ($1, $2, $3) RETURNING id
`, ['Santander', '5678', contribuyenteId]);
console.log('✅ Bancos creados');
// Generar CFDIs
const tipos: Array<'EMITIDO' | 'RECIBIDO'> = ['EMITIDO', 'RECIBIDO'];
const cfdiIds: number[] = [];
for (let i = 0; i < 60; i++) {
const tipo = tipos[i % 2];
const esEmitido = tipo === 'EMITIDO';
const contraparte = esEmitido
? CLIENTES[i % CLIENTES.length]
: PROVEEDORES[i % PROVEEDORES.length];
const subtotal = Math.floor(Math.random() * 40000) + 2000;
const iva = Math.round(subtotal * 0.16 * 100) / 100;
const total = Math.round((subtotal + iva) * 100) / 100;
const daysAgo = Math.floor(Math.random() * 540); // hasta ~18 meses atrás
const fecha = new Date();
fecha.setDate(fecha.getDate() - daysAgo);
fecha.setHours(10 + (i % 8), 0, 0, 0);
const year = String(fecha.getFullYear());
const month = String(fecha.getMonth() + 1).padStart(2, '0');
const fechaStr = fecha.toISOString();
const metodoPago = Math.random() > 0.3 ? 'PUE' : 'PPD';
const formasPago = ['01', '02', '03', '04'];
const formaPago = formasPago[i % formasPago.length];
const usoCfdi = esEmitido ? 'G03' : 'G01';
const rfcEmisor = esEmitido ? DEMO.rfc : contraparte.rfc;
const nombreEmisor = esEmitido ? DEMO.nombre : contraparte.nombre;
const rfcReceptor = esEmitido ? contraparte.rfc : DEMO.rfc;
const nombreReceptor = esEmitido ? contraparte.nombre : DEMO.nombre;
const { rows: [cfdi] } = await client.query<{ id: number }>(`
INSERT INTO cfdis (
year, month, type, uuid, serie, folio, status, fecha_emision,
rfc_emisor_id, rfc_emisor, nombre_emisor,
rfc_receptor_id, rfc_receptor, nombre_receptor,
subtotal, subtotal_mxn, descuento, descuento_mxn,
total, total_mxn, moneda, tipo_cambio, tipo_comprobante,
metodo_pago, forma_pago, uso_cfdi,
iva_traslado, iva_traslado_mxn,
regimen_fiscal_emisor, regimen_fiscal_receptor,
contribuyente_id, fecha_efectiva, meses_global, año_global
) 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,
$27, $28,
$29, $30,
$31, $32, $33, $34
) RETURNING id
`, [
year, month, tipo, randomUUID(), 'DEMO', String(1000 + i),
'Vigente', fechaStr,
rfcs.get(rfcEmisor), rfcEmisor, nombreEmisor,
rfcs.get(rfcReceptor), rfcReceptor, nombreReceptor,
subtotal, subtotal, 0, 0,
total, total, 'MXN', 1, 'I',
metodoPago, formaPago, usoCfdi,
iva, iva,
'601', '601',
contribuyenteId, fechaStr, month, year,
]);
cfdiIds.push(cfdi.id);
// Conceptos
const numConceptos = Math.floor(Math.random() * 3) + 1;
for (let j = 0; j < numConceptos; j++) {
const prod = PRODUCTOS[(i + j) % PRODUCTOS.length];
const cantidad = Math.floor(Math.random() * 5) + 1;
const valorUnitario = Math.floor(Math.random() * 4000) + 500;
const importe = Math.round(cantidad * valorUnitario * 100) / 100;
const ivaConcepto = Math.round(importe * 0.16 * 100) / 100;
await client.query(`
INSERT INTO cfdi_conceptos (
cfdi_id, clave_prod_serv, descripcion, cantidad, clave_unidad, unidad,
valor_unitario, valor_unitario_mxn, importe, importe_mxn,
iva_traslado, iva_traslado_mxn
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
`, [
cfdi.id, prod.clave, prod.descripcion, cantidad, 'E48', prod.unidad,
valorUnitario, valorUnitario, importe, importe,
ivaConcepto, ivaConcepto,
]);
}
}
console.log('✅ 60 CFDIs y conceptos creados');
// Conciliaciones para algunos CFDIs PPD pagados con transferencia (forma 02/03)
const { rows: cfdisPpd } = await client.query<{ id: number; year: string; month: string }>(`
SELECT id, year, month FROM cfdis
WHERE metodo_pago = 'PPD' AND forma_pago IN ('02', '03')
ORDER BY id LIMIT 15
`);
for (const c of cfdisPpd) {
const bancoId = Math.random() > 0.5 ? banco1.id : banco2.id;
const fechaPago = new Date();
fechaPago.setDate(fechaPago.getDate() - Math.floor(Math.random() * 30));
await client.query(`
INSERT INTO conciliaciones (anio, mes, id_cfdi, fecha_de_pago, id_banco)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (id_cfdi) DO NOTHING
`, [c.year, c.month, c.id, fechaPago.toISOString().split('T')[0], bancoId]);
}
console.log('✅ Conciliaciones creadas');
// Obligaciones fiscales asignadas al contribuyente
const obligaciones = [
{ id: 'isr-provisional', nombre: 'Pago provisional de ISR', categoria: 'Federal mensual' },
{ id: 'iva-mensual', nombre: 'Pago mensual definitivo de IVA', categoria: 'Federal mensual' },
{ id: 'ret-isr-honorarios', nombre: 'Retenciones de ISR por honorarios y arrendamiento a PF', categoria: 'Federal mensual' },
{ id: 'diot', nombre: 'DIOT', categoria: 'Informativa mensual' },
{ id: 'imss-cuotas', nombre: 'Cuotas obrero-patronales IMSS', categoria: 'Seguridad social' },
{ id: 'anual-isr-pm', nombre: 'Declaración Anual de ISR PM', categoria: 'Anual' },
{ id: 'isn', nombre: 'ISN - Impuesto Sobre Nómina', categoria: 'Estatal' },
{ id: 'isrtp', nombre: 'Impuesto sobre remuneración al trabajo', categoria: 'Estatal' },
];
for (const o of obligaciones) {
await client.query(`
INSERT INTO obligaciones_contribuyente (
contribuyente_id, catalogo_id, nombre, frecuencia, fecha_limite, categoria, activa, es_recomendada
) VALUES ($1, $2, $3, $4, $5, $6, true, true)
ON CONFLICT DO NOTHING
`, [contribuyenteId, o.id, o.nombre, 'mensual', 'Día 17 del mes siguiente', o.categoria]);
}
console.log('✅ Obligaciones fiscales asignadas');
// Cartera principal con el contribuyente
const { rows: [cartera] } = await client.query<{ id: string }>(`
INSERT INTO carteras (supervisor_user_id, nombre, descripcion)
VALUES ($1, $2, $3) RETURNING id
`, [ownerId, 'Cartera Principal', 'Clientes y prospectos de Demo Ventas']);
await client.query(`
INSERT INTO cartera_entidades (cartera_id, entidad_id)
VALUES ($1, $2)
ON CONFLICT DO NOTHING
`, [cartera.id, contribuyenteId]);
console.log('✅ Cartera principal creada');
// Alertas y recordatorios de ejemplo
await client.query(`
INSERT INTO alertas (tipo, titulo, mensaje, prioridad, fecha_vencimiento)
VALUES
('obligacion', 'Declaración mensual de IVA', 'Pago de IVA correspondiente a mayo 2026', 'alta', NOW() + INTERVAL '10 days'),
('obligacion', 'Pago provisional ISR', 'Pago provisional de ISR de mayo 2026', 'alta', NOW() + INTERVAL '10 days'),
('sat', 'Sincronización SAT pendiente', 'Última sincronización hace más de 7 días', 'media', NOW() + INTERVAL '3 days')
`);
await client.query(`
INSERT INTO recordatorios (titulo, descripcion, fecha_limite, notas, completado, privado, creado_por)
VALUES
('Revisar estados de cuenta', 'Conciliar pagos de clientes', NOW() + INTERVAL '5 days', 'Prioridad alta', false, false, $1),
('Enviar facturas del mes', 'Facturación recurrente a clientes', NOW() + INTERVAL '7 days', 'Clientes Alfa y Beta', false, false, $1)
`, [ownerId]);
console.log('✅ Alertas y recordatorios de ejemplo creados');
await client.query('COMMIT');
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}
main()
.catch((e) => {
console.error('\n❌ Error creando demo:', e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
await tenantDb.shutdown();
});

View File

@@ -0,0 +1,119 @@
/**
* Script: create-vendedor-fernando.ts
*
* Crea la cuenta de Fernando (fernando@horuxfin.com) como Vendedor de Horux 360.
* Rol de plataforma: platform_sales (Vendedor).
* Membership en el tenant Horux 360 con rol cliente (minimo, solo para login
* y acceso a configuracion/cambio de contraseña).
*
* Si el usuario ya existe, le asigna/actualiza los permisos y envia un correo
* de notificacion. Si es nuevo, genera password temporal y envia bienvenida.
*
* Uso:
* cd apps/api && npx tsx scripts/create-vendedor-fernando.ts
*/
import { randomBytes } from 'crypto';
import { prisma } from '../src/config/database.js';
import { hashPassword } from '../src/auth/passwords.js';
import { emailService } from '../src/services/email/email.service.js';
import { invalidatePlatformRolesCache } from '../src/utils/platform-admin.js';
const EMAIL = 'fernando@horuxfin.com';
const NOMBRE = 'Fernando';
const HORUX_RFC = 'HTS240708LJA';
function generarPassword(): string {
return randomBytes(6).toString('hex'); // 12 caracteres hex
}
async function main() {
console.log(`🌱 Creando cuenta de Vendedor para ${EMAIL}...\n`);
// 1. Tenant raiz Horux 360
const tenant = await prisma.tenant.findUnique({ where: { rfc: HORUX_RFC } });
if (!tenant) throw new Error(`Tenant Horux 360 (${HORUX_RFC}) no encontrado. Ejecuta primero el bootstrap admin global.`);
// 2. Rol "cliente" para la membership (minimo acceso)
const clienteRol = await prisma.rol.findUnique({ where: { nombre: 'cliente' } });
if (!clienteRol) throw new Error('Rol "cliente" no encontrado en BD central');
// 3. Buscar o crear usuario
let user = await prisma.user.findUnique({ where: { email: EMAIL } });
let tempPassword: string | null = null;
let esNuevo = false;
if (!user) {
tempPassword = generarPassword();
const passwordHash = await hashPassword(tempPassword);
user = await prisma.user.create({
data: {
email: EMAIL,
passwordHash,
nombre: NOMBRE,
lastTenantId: tenant.id,
active: true,
},
});
esNuevo = true;
console.log(`✅ Usuario creado: ${user.email}`);
console.log(` Password temporal: ${tempPassword}`);
} else {
await prisma.user.update({
where: { id: user.id },
data: { lastTenantId: tenant.id, active: true },
});
console.log(` Usuario ya existia: ${user.email}`);
}
// 4. Membership en Horux 360 con rol cliente
await prisma.tenantMembership.upsert({
where: { userId_tenantId: { userId: user.id, tenantId: tenant.id } },
update: { rolId: clienteRol.id, active: true, isOwner: false },
create: {
userId: user.id,
tenantId: tenant.id,
rolId: clienteRol.id,
active: true,
isOwner: false,
},
});
console.log(`✅ Membership "cliente" en ${tenant.nombre}`);
// 5. Rol de plataforma Vendedor (platform_sales)
await prisma.userPlatformRole.upsert({
where: { userId_role: { userId: user.id, role: 'platform_sales' } },
update: {},
create: { userId: user.id, role: 'platform_sales' },
});
console.log(`✅ Rol de plataforma "Vendedor" (platform_sales) asignado`);
// 6. Invalidar cache de roles de plataforma
invalidatePlatformRolesCache(user.id);
// 7. Enviar correo con accesos (solo si es nuevo; si ya existia, no se reenvia password)
if (esNuevo && tempPassword) {
await emailService.sendWelcome(EMAIL, {
nombre: NOMBRE,
email: EMAIL,
tempPassword,
});
console.log(`✅ Correo de bienvenida con credenciales enviado a ${EMAIL}`);
} else {
console.log(` El usuario ya existia; no se envio correo con password`);
}
console.log('\n🎉 Cuenta de Vendedor lista');
console.log(` Email: ${EMAIL}`);
if (tempPassword) console.log(` Password temporal: ${tempPassword}`);
console.log(` Tenant: ${tenant.nombre} (${tenant.rfc})`);
console.log(` Rol de plataforma: platform_sales (Vendedor)`);
}
main()
.catch((err) => {
console.error('\n❌ Error:', err.message || err);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

View File

@@ -0,0 +1,126 @@
/**
* Script: fix-demo-carteras-asignaciones
*
* Corrige la estructura de carteras de Demo Ventas para que las asignaciones
* de obligaciones/tareas al auxiliar sean válidas:
* - La cartera principal queda solo para el supervisor.
* - Se crea una subcartera asignada al auxiliar.
* - Los contribuyentes se mueven a la subcartera del auxiliar.
* - Se mantiene la relación auxiliar → supervisor.
*
* Ejecución:
* cd apps/api && npx tsx scripts/fix-demo-carteras-asignaciones.ts
*/
import { PrismaClient } from '@prisma/client';
import { tenantDb } from '../src/config/database.ts';
const prisma = new PrismaClient();
const DEMO_RFC = 'DEMO2501019X2';
async function main() {
console.log('🔧 Corrigiendo carteras y asignaciones de Demo Ventas...\n');
const tenant = await prisma.tenant.findUnique({ where: { rfc: DEMO_RFC } });
if (!tenant) throw new Error(`Tenant ${DEMO_RFC} no encontrado`);
const [supervisor, auxiliar] = await Promise.all([
prisma.user.findUnique({ where: { email: 'supervisor@horuxfin.com' } }),
prisma.user.findUnique({ where: { email: 'auxiliar@horuxfin.com' } }),
]);
if (!supervisor) throw new Error('Usuario supervisor no encontrado');
if (!auxiliar) throw new Error('Usuario auxiliar no encontrado');
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
const client = await pool.connect();
try {
await client.query('BEGIN');
// Obtener cartera principal
const { rows: [carteraPrincipal] } = await client.query<{ id: string }>(`
SELECT id FROM carteras WHERE parent_id IS NULL ORDER BY created_at LIMIT 1
`);
if (!carteraPrincipal) throw new Error('No existe cartera principal');
// Crear subcartera para el auxiliar
const { rows: [subcartera] } = await client.query<{ id: string }>(`
INSERT INTO carteras (supervisor_user_id, auxiliar_user_id, nombre, descripcion, parent_id)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT DO NOTHING
RETURNING id
`, [supervisor.id, auxiliar.id, 'Cartera Auxiliar Demo', 'RFCs asignados al auxiliar de demo', carteraPrincipal.id]);
const subcarteraId = subcartera?.id;
if (!subcarteraId) {
// Si ya existía, recuperarla
const { rows: [existing] } = await client.query<{ id: string }>(`
SELECT id FROM carteras WHERE parent_id = $1 AND auxiliar_user_id = $2 LIMIT 1
`, [carteraPrincipal.id, auxiliar.id]);
if (!existing) throw new Error('No se pudo crear ni recuperar la subcartera del auxiliar');
// Asegurar que tenga supervisor
await client.query(`UPDATE carteras SET supervisor_user_id = $1 WHERE id = $2`, [supervisor.id, existing.id]);
}
const finalSubcarteraId = subcarteraId || (await client.query<{ id: string }>(`SELECT id FROM carteras WHERE parent_id = $1 AND auxiliar_user_id = $2 LIMIT 1`, [carteraPrincipal.id, auxiliar.id])).rows[0].id;
console.log('✅ Subcartera del auxiliar creada/recuperada');
// Mover contribuyentes de la cartera principal a la subcartera del auxiliar
const { rows: entidades } = await client.query<{ entidad_id: string }>(`
SELECT entidad_id FROM cartera_entidades WHERE cartera_id = $1
`, [carteraPrincipal.id]);
for (const e of entidades) {
await client.query(`
INSERT INTO cartera_entidades (cartera_id, entidad_id)
VALUES ($1, $2)
ON CONFLICT DO NOTHING
`, [finalSubcarteraId, e.entidad_id]);
}
// Quitar contribuyentes de la cartera principal (ahora están en la subcartera)
await client.query(`DELETE FROM cartera_entidades WHERE cartera_id = $1`, [carteraPrincipal.id]);
// La cartera principal ya no tiene auxiliar asignado
await client.query(`UPDATE carteras SET auxiliar_user_id = NULL WHERE id = $1`, [carteraPrincipal.id]);
await client.query('COMMIT');
console.log(`${entidades.length} contribuyentes movidos a la subcartera del auxiliar`);
console.log('✅ Cartera principal limpia (sin auxiliar)');
// Asegurar relación auxiliar → supervisor
await pool.query(`
INSERT INTO auxiliar_supervisores (auxiliar_user_id, supervisor_user_id)
VALUES ($1, $2)
ON CONFLICT (auxiliar_user_id) DO UPDATE SET supervisor_user_id = EXCLUDED.supervisor_user_id
`, [auxiliar.id, supervisor.id]);
console.log('✅ Relación auxiliar → supervisor registrada');
// Validar: el auxiliar debe ser elegible para todos los contribuyentes
const { rows: elegibles } = await pool.query<{ entidad_id: string }>(`
SELECT DISTINCT ce.entidad_id
FROM carteras c
JOIN cartera_entidades ce ON ce.cartera_id = c.id
WHERE c.auxiliar_user_id = $1
`, [auxiliar.id]);
console.log(`✅ Auxiliar elegible para ${elegibles.length} contribuyentes`);
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
console.log('\n🎉 Estructura de carteras corregida');
}
main()
.catch((e) => {
console.error('\n❌ Error:', e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
await tenantDb.shutdown();
});

View File

@@ -0,0 +1,56 @@
import fs from 'fs';
import readline from 'readline';
import { prisma } from '../src/config/database.js';
const BATCH_SIZE = 2000;
const CSV_PATH = process.argv[2] || '/tmp/claves_prod_serv.csv';
async function main() {
if (!fs.existsSync(CSV_PATH)) {
console.error(`Archivo no encontrado: ${CSV_PATH}`);
console.error('Uso: npx tsx scripts/import-clave-prod-serv.ts [ruta/al/csv]');
process.exit(1);
}
const existing = await prisma.catClaveProdServ.count();
console.log(`Registros existentes: ${existing}`);
if (existing > 0) {
console.log('El catálogo ya tiene datos. No se importará nada.');
process.exit(0);
}
const fileStream = fs.createReadStream(CSV_PATH, { encoding: 'utf-8' });
const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity });
let batch: { clave: string; descripcion: string }[] = [];
let total = 0;
for await (const line of rl) {
const idx = line.indexOf(',');
if (idx === -1) continue;
const clave = line.slice(0, idx).trim();
const descripcion = line.slice(idx + 1).trim();
if (!clave || !descripcion) continue;
batch.push({ clave, descripcion });
if (batch.length >= BATCH_SIZE) {
await prisma.catClaveProdServ.createMany({ data: batch, skipDuplicates: true });
total += batch.length;
console.log(`Importados: ${total}`);
batch = [];
}
}
if (batch.length > 0) {
await prisma.catClaveProdServ.createMany({ data: batch, skipDuplicates: true });
total += batch.length;
}
console.log(`Importación completada. Total: ${total}`);
process.exit(0);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

View File

@@ -0,0 +1,67 @@
/**
* Script: resend-welcome
*
* Genera una nueva contraseña temporal para el usuario y reenvía el correo
* de bienvenida. Útil cuando el envío anterior falló o se perdió.
*
* Ejecución:
* cd apps/api && npx tsx scripts/resend-welcome.ts
*/
import { PrismaClient } from '@prisma/client';
import bcrypt from 'bcryptjs';
import { randomBytes } from 'crypto';
import { emailService } from '../src/services/email/email.service.js';
const prisma = new PrismaClient();
const EMAIL = 'miguel.corona@corpcyl.com';
function generateTempPassword(length = 12): string {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789';
let result = '';
const bytes = randomBytes(length);
for (let i = 0; i < length; i++) {
result += chars[bytes[i] % chars.length];
}
return result + '!';
}
async function main() {
const user = await prisma.user.findUnique({ where: { email: EMAIL } });
if (!user) {
console.error(`❌ No existe un usuario con el correo ${EMAIL}`);
process.exit(1);
}
const tempPassword = generateTempPassword();
const passwordHash = await bcrypt.hash(tempPassword, 12);
await prisma.user.update({
where: { id: user.id },
data: {
passwordHash,
tokenVersion: { increment: 1 },
},
});
console.log('⏳ Enviando correo de bienvenida a', EMAIL, '...');
await emailService.sendWelcome(EMAIL, {
nombre: user.nombre,
email: EMAIL,
tempPassword,
});
console.log('✅ Correo de bienvenida enviado');
console.log(' Nombre:', user.nombre);
console.log(' Email:', EMAIL);
console.log(' Contraseña temporal:', tempPassword);
}
main()
.catch((e) => {
console.error('\n❌ Error enviando correo:', e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

View File

@@ -0,0 +1,112 @@
/**
* Script: reset-demo-asignaciones
*
* Deja el tenant Demo Ventas listo para que el usuario haga manualmente
* el flujo de asignación de carteras, obligaciones y tareas (útiles para tutoriales):
* - Elimina la subcartera del auxiliar.
* - Deja todos los contribuyentes en la cartera principal (sin auxiliar).
* - Elimina asignaciones de obligaciones y tareas.
* - Elimina la relación auxiliar → supervisor.
*
* Ejecución:
* cd apps/api && npx tsx scripts/reset-demo-asignaciones.ts
*/
import { PrismaClient } from '@prisma/client';
import { tenantDb } from '../src/config/database.ts';
const prisma = new PrismaClient();
const DEMO_RFC = 'DEMO2501019X2';
async function findUserIdByEmail(email: string): Promise<string | null> {
const rows = await prisma.$queryRawUnsafe<{ id: string }[]>(
`SELECT id FROM users WHERE email = $1 LIMIT 1`,
email,
);
return rows[0]?.id ?? null;
}
async function main() {
console.log('🔄 Reseteando asignaciones de Demo Ventas para tutoriales...\n');
const tenants = await prisma.$queryRawUnsafe<{ id: string; database_name: string }[]>(
`SELECT id, database_name FROM tenants WHERE rfc = $1 LIMIT 1`,
DEMO_RFC,
);
const tenant = tenants[0];
if (!tenant) throw new Error(`Tenant ${DEMO_RFC} no encontrado`);
const supervisorId = await findUserIdByEmail('supervisor@horuxfin.com');
if (!supervisorId) throw new Error('Usuario supervisor no encontrado');
const auxiliarId = await findUserIdByEmail('auxiliar@horuxfin.com');
const pool = await tenantDb.getPool(tenant.id, tenant.database_name);
const client = await pool.connect();
try {
await client.query('BEGIN');
// Eliminar asignaciones de obligaciones y tareas
await client.query('DELETE FROM obligacion_asignaciones');
await client.query('DELETE FROM tarea_asignaciones');
console.log('✅ Asignaciones de obligaciones y tareas eliminadas');
// Obtener cartera principal
const { rows: [carteraPrincipal] } = await client.query<{ id: string }>(`
SELECT id FROM carteras WHERE parent_id IS NULL ORDER BY created_at LIMIT 1
`);
if (!carteraPrincipal) throw new Error('No existe cartera principal');
// Eliminar subcarteras (borra también cartera_entidades en cascade si hay FK)
await client.query('DELETE FROM cartera_entidades WHERE cartera_id != $1', [carteraPrincipal.id]);
await client.query('DELETE FROM carteras WHERE parent_id = $1', [carteraPrincipal.id]);
console.log('✅ Subcarteras eliminadas');
// Limpiar cartera principal: sin auxiliar, supervisor demo
await client.query(`
UPDATE carteras SET auxiliar_user_id = NULL, supervisor_user_id = $1 WHERE id = $2
`, [supervisorId, carteraPrincipal.id]);
// Agregar todos los contribuyentes a la cartera principal
const { rows: contribuyentes } = await client.query<{ entidad_id: string }>(`
SELECT entidad_id FROM contribuyentes
`);
for (const c of contribuyentes) {
await client.query(`
INSERT INTO cartera_entidades (cartera_id, entidad_id)
VALUES ($1, $2)
ON CONFLICT DO NOTHING
`, [carteraPrincipal.id, c.entidad_id]);
}
console.log(`${contribuyentes.length} contribuyentes dejados en Cartera Principal`);
// Eliminar relación auxiliar → supervisor para que se cree en el tutorial
if (auxiliarId) {
await client.query('DELETE FROM auxiliar_supervisores WHERE auxiliar_user_id = $1', [auxiliarId]);
console.log('✅ Relación auxiliar → supervisor eliminada');
}
await client.query('COMMIT');
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
console.log('\n🎉 Demo Ventas listo para tutoriales');
console.log(' - Cartera Principal con 6 contribuyentes, sin auxiliar');
console.log(' - 48 obligaciones y 24 tareas sin asignar');
console.log(' - Usuarios: owner, supervisor, auxiliar, cliente');
}
main()
.catch((e) => {
console.error('\n❌ Error:', e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
await tenantDb.shutdown();
});

View File

@@ -0,0 +1,124 @@
/**
* Script: seed-demo-obligaciones-tareas
*
* Crea obligaciones fiscales y tareas recurrentes para todos los contribuyentes
* del tenant Demo Ventas. Además asigna el usuario auxiliar a las tareas y
* obligaciones, y lo vincula a la cartera principal.
*
* Ejecución:
* cd apps/api && npx tsx scripts/seed-demo-obligaciones-tareas.ts
*/
import { PrismaClient } from '@prisma/client';
import { tenantDb } from '../src/config/database.ts';
import { seedTareasDefault, materializarPeriodos } from '../src/services/tareas.service.ts';
const prisma = new PrismaClient();
const DEMO_RFC = 'DEMO2501019X2';
const OBLIGACIONES = [
{ id: 'isr-provisional', nombre: 'Pago provisional de ISR', fundamento: 'Art. 14 LISR', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', categoria: 'Federal mensual' },
{ id: 'iva-mensual', nombre: 'Pago mensual definitivo de IVA', fundamento: 'Art. 5-D LIVA', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', categoria: 'Federal mensual' },
{ id: 'ret-isr-honorarios', nombre: 'Retenciones de ISR por honorarios y arrendamiento a PF', fundamento: 'Art. 106/116 LISR', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', categoria: 'Federal mensual' },
{ id: 'diot', nombre: 'DIOT (Declaración Informativa de Operaciones con Terceros)', fundamento: 'Art. 32 LIVA', frecuencia: 'mensual', fechaLimite: 'Último día del mes siguiente', categoria: 'Informativa mensual' },
{ id: 'imss-cuotas', nombre: 'Cuotas obrero-patronales IMSS', fundamento: 'LSS', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', categoria: 'Seguridad social' },
{ id: 'anual-isr-pm', nombre: 'Declaración Anual de ISR PM', fundamento: 'Art. 76 LISR', frecuencia: 'anual', fechaLimite: '31 de marzo', categoria: 'Anual' },
{ id: 'isn', nombre: 'ISN - Impuesto Sobre Nómina', fundamento: 'Ley estatal', frecuencia: 'mensual', fechaLimite: 'Varía por estado (CDMX día 17)', categoria: 'Estatal' },
{ id: 'isrtp', nombre: 'Impuesto sobre remuneración al trabajo', fundamento: 'Ley estatal', frecuencia: 'mensual', fechaLimite: 'Día 10 del mes siguiente', categoria: 'Estatal' },
];
async function main() {
console.log('🌱 Sembrando obligaciones y tareas en Demo Ventas...\n');
const tenant = await prisma.tenant.findUnique({ where: { rfc: DEMO_RFC } });
if (!tenant) throw new Error(`Tenant ${DEMO_RFC} no encontrado`);
const auxUser = await prisma.user.findUnique({ where: { email: 'auxiliar@horuxfin.com' } });
if (!auxUser) throw new Error('Usuario auxiliar no encontrado');
const supervisorUser = await prisma.user.findUnique({ where: { email: 'supervisor@horuxfin.com' } });
if (!supervisorUser) throw new Error('Usuario supervisor no encontrado');
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
const { rows: contribuyentes } = await pool.query<{ id: string; rfc: string }>(`
SELECT entidad_id AS id, rfc FROM contribuyentes ORDER BY rfc
`);
if (contribuyentes.length === 0) throw new Error('No hay contribuyentes en el tenant demo');
for (const c of contribuyentes) {
// Obligaciones fiscales (idempotente: evita duplicados por contribuyente + catalogo_id)
let obligacionesCreadas = 0;
for (const o of OBLIGACIONES) {
const { rows: existing } = await pool.query(
`SELECT 1 FROM obligaciones_contribuyente WHERE contribuyente_id = $1 AND catalogo_id = $2 LIMIT 1`,
[c.id, o.id],
);
if (existing.length > 0) continue;
await pool.query(`
INSERT INTO obligaciones_contribuyente (
contribuyente_id, catalogo_id, nombre, fundamento, frecuencia, fecha_limite, categoria, activa, es_recomendada
) VALUES ($1, $2, $3, $4, $5, $6, $7, true, true)
`, [c.id, o.id, o.nombre, o.fundamento, o.frecuencia, o.fechaLimite, o.categoria]);
obligacionesCreadas++;
}
console.log(`${c.rfc}: ${obligacionesCreadas} obligaciones creadas`);
// Tareas default
const tareasCreadas = await seedTareasDefault(pool, c.id);
if (tareasCreadas > 0) {
await materializarPeriodos(pool, c.id);
console.log(`${c.rfc}: ${tareasCreadas} tareas creadas y periodos materializados`);
} else {
console.log(` ${c.rfc}: tareas default ya existían`);
}
}
// Asignar auxiliar a todas las obligaciones y tareas activas
await pool.query(`
INSERT INTO obligacion_asignaciones (obligacion_id, auxiliar_user_id, asignado_por)
SELECT oc.id, $1, $2
FROM obligaciones_contribuyente oc
WHERE oc.activa = true
ON CONFLICT (obligacion_id) DO UPDATE SET auxiliar_user_id = EXCLUDED.auxiliar_user_id, asignado_por = EXCLUDED.asignado_por
`, [auxUser.id, supervisorUser.id]);
console.log('✅ Auxiliar asignado a obligaciones');
await pool.query(`
INSERT INTO tarea_asignaciones (tarea_id, auxiliar_user_id, asignado_por)
SELECT tc.id, $1, $2
FROM tareas_catalogo tc
WHERE tc.active = true
ON CONFLICT (tarea_id) DO UPDATE SET auxiliar_user_id = EXCLUDED.auxiliar_user_id, asignado_por = EXCLUDED.asignado_por
`, [auxUser.id, supervisorUser.id]);
console.log('✅ Auxiliar asignado a tareas');
// Asignar auxiliar a la cartera principal
await pool.query(`
UPDATE carteras SET auxiliar_user_id = $1
WHERE parent_id IS NULL
`, [auxUser.id]);
console.log('✅ Auxiliar asignado a la cartera principal');
// Asegurar relación auxiliar-supervisor
await pool.query(`
INSERT INTO auxiliar_supervisores (auxiliar_user_id, supervisor_user_id)
VALUES ($1, $2)
ON CONFLICT (auxiliar_user_id) DO UPDATE SET supervisor_user_id = EXCLUDED.supervisor_user_id
`, [auxUser.id, supervisorUser.id]);
console.log('✅ Relación auxiliar → supervisor registrada');
console.log('\n🎉 Obligaciones y tareas listas en Demo Ventas');
}
main()
.catch((e) => {
console.error('\n❌ Error:', e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
await tenantDb.shutdown();
});

View File

@@ -0,0 +1,337 @@
/**
* Script: update-demo-ventas
*
* Agrega al tenant Demo Ventas:
* - 5 contribuyentes adicionales
* - Usuarios supervisor, auxiliar y cliente con sus memberships
* - CFDIs de ejemplo para los nuevos contribuyentes
* - Accesos de cliente a los contribuyentes
* - Ajusta el plan custom para soportar más RFCs/usuarios
*
* Ejecución:
* cd apps/api && npx tsx scripts/update-demo-ventas.ts
*/
import { PrismaClient } from '@prisma/client';
import { Pool } from 'pg';
import bcrypt from 'bcryptjs';
import { randomUUID } from 'crypto';
import { tenantDb } from '../src/config/database.ts';
const prisma = new PrismaClient();
const DEMO_RFC = 'DEMO2501019X2';
const DEFAULT_PASSWORD = 'Demo12345!';
const NUEVOS_CONTRIBUYENTES = [
{ rfc: 'COM2501019X1', nombre: 'Comercial del Norte SA de CV', cp: '64000' },
{ rfc: 'DIS2501019X1', nombre: 'Distribuidora del Centro SA de CV', cp: '44100' },
{ rfc: 'SIS2501019X1', nombre: 'Servicios Integrales del Sur SA de CV', cp: '86000' },
{ rfc: 'IMP2501019X1', nombre: 'Importadora del Pacifico SA de CV', cp: '82140' },
{ rfc: 'EXA2501019X1', nombre: 'Exportadora del Atlantico SA de CV', cp: '94270' },
];
const USUARIOS = [
{ email: 'supervisor@horuxfin.com', nombre: 'Supervisor Demo', rol: 'supervisor' },
{ email: 'auxiliar@horuxfin.com', nombre: 'Auxiliar Demo', rol: 'auxiliar' },
{ email: 'cliente@horuxfin.com', nombre: 'Cliente Demo', rol: 'cliente' },
];
const CLIENTES = [
{ rfc: 'CLI123456AB1', nombre: 'Cliente Alfa SA' },
{ rfc: 'CLI123456AB2', nombre: 'Cliente Beta SA' },
{ rfc: 'CLI123456AB3', nombre: 'Cliente Gamma SA' },
];
const PROVEEDORES = [
{ rfc: 'PRO123456AB1', nombre: 'Proveedor Materiales SA' },
{ rfc: 'PRO123456AB2', nombre: 'Proveedor Servicios SA' },
{ rfc: 'PRO123456AB3', nombre: 'Proveedor Logistica SA' },
];
const PRODUCTOS = [
{ clave: '84111506', descripcion: 'Servicio de consultoria', unidad: 'Servicio' },
{ clave: '43232408', descripcion: 'Licencia de software', unidad: 'Licencia' },
{ clave: '81141500', descripcion: 'Soporte tecnico', unidad: 'Servicio' },
{ clave: '81121700', descripcion: 'Desarrollo web', unidad: 'Servicio' },
{ clave: '86101500', descripcion: 'Capacitacion', unidad: 'Servicio' },
];
async function main() {
console.log('🌱 Actualizando Demo Ventas...\n');
const tenant = await prisma.tenant.findUnique({ where: { rfc: DEMO_RFC } });
if (!tenant) throw new Error(`Tenant ${DEMO_RFC} no encontrado`);
// Ajustar catálogo del plan custom para soportar la demo completa
await prisma.despachoPlanPrice.update({
where: { plan: 'custom' },
data: { maxRfcs: 10, maxUsers: 10 },
});
console.log('✅ Plan custom actualizado: maxRfcs=10, maxUsers=10');
// Crear/actualizar usuarios y memberships
const createdUsers: Record<string, { id: string; rolId: number }> = {};
for (const u of USUARIOS) {
const rol = await prisma.rol.findUnique({ where: { nombre: u.rol } });
if (!rol) throw new Error(`Rol ${u.rol} no encontrado`);
let user = await prisma.user.findUnique({ where: { email: u.email } });
const passwordHash = await bcrypt.hash(DEFAULT_PASSWORD, 12);
if (!user) {
user = await prisma.user.create({
data: { email: u.email, passwordHash, nombre: u.nombre, lastTenantId: tenant.id },
});
} else {
user = await prisma.user.update({ where: { id: user.id }, data: { passwordHash, lastTenantId: tenant.id } });
}
await prisma.tenantMembership.upsert({
where: { userId_tenantId: { userId: user.id, tenantId: tenant.id } },
update: { rolId: rol.id, active: true, isOwner: false },
create: { userId: user.id, tenantId: tenant.id, rolId: rol.id, active: true, isOwner: false },
});
createdUsers[u.rol] = { id: user.id, rolId: rol.id };
console.log(`✅ Usuario ${u.rol}:`, u.email);
}
const supervisorId = createdUsers.supervisor.id;
const clienteId = createdUsers.cliente.id;
// Conectar a BD del tenant
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
// Crear contribuyentes, CFDIs y accesos
const contribuyenteIds: string[] = [];
for (const c of NUEVOS_CONTRIBUYENTES) {
const id = await crearContribuyente(pool, c, supervisorId, tenant.id);
contribuyenteIds.push(id);
console.log(`✅ Contribuyente creado: ${c.rfc}`);
await crearCfdis(pool, id, c.rfc, c.nombre);
}
// Asignar accesos de cliente a todos los contribuyentes (incluido el original)
const { rows: todasEntidades } = await pool.query<{ id: string }>(`
SELECT entidad_id AS id FROM contribuyentes
`);
for (const e of todasEntidades) {
await pool.query(`
INSERT INTO cliente_accesos (user_id, entidad_id) VALUES ($1, $2)
ON CONFLICT DO NOTHING
`, [clienteId, e.id]);
}
console.log('✅ Accesos de cliente asignados a', todasEntidades.length, 'contribuyentes');
// Agregar nuevos contribuyentes a la cartera principal
const { rows: [cartera] } = await pool.query<{ id: string }>(`
SELECT id FROM carteras ORDER BY created_at LIMIT 1
`);
if (cartera) {
for (const id of contribuyenteIds) {
await pool.query(`
INSERT INTO cartera_entidades (cartera_id, entidad_id) VALUES ($1, $2)
ON CONFLICT DO NOTHING
`, [cartera.id, id]);
}
console.log('✅ Nuevos contribuyentes agregados a cartera principal');
}
console.log('\n🎉 Demo Ventas actualizada');
console.log(' Nuevos contribuyentes:', NUEVOS_CONTRIBUYENTES.length);
console.log(' Usuos adicionales:');
for (const u of USUARIOS) {
console.log(` ${u.rol}: ${u.email} / ${DEFAULT_PASSWORD}`);
}
}
async function crearContribuyente(pool: Pool, data: { rfc: string; nombre: string; cp: string }, supervisorId: string, tenantId: string): Promise<string> {
const client = await pool.connect();
try {
await client.query('BEGIN');
// Buscar si ya existe la entidad para este RFC
const { rows: existingEntidad } = await client.query<{ id: string }>(`
SELECT e.id FROM entidades_gestionadas e
WHERE e.identificador = $1 AND e.tipo = 'CONTRIBUYENTE'
`, [data.rfc]);
let entidadId: string;
if (existingEntidad.length > 0) {
entidadId = existingEntidad[0].id;
await client.query(`
UPDATE entidades_gestionadas
SET nombre = $1, supervisor_user_id = $2, updated_at = now()
WHERE id = $3
`, [data.nombre, supervisorId, entidadId]);
} else {
const { rows: [entidad] } = await client.query<{ id: string }>(`
INSERT INTO entidades_gestionadas (tipo, nombre, identificador, supervisor_user_id)
VALUES ('CONTRIBUYENTE', $1, $2, $3)
RETURNING id
`, [data.nombre, data.rfc, supervisorId]);
entidadId = entidad.id;
}
const { rows: existingContrib } = await client.query<{ entidad_id: string }>(`
SELECT entidad_id FROM contribuyentes WHERE entidad_id = $1
`, [entidadId]);
if (existingContrib.length > 0) {
await client.query(`
UPDATE contribuyentes
SET rfc = $1, regimen_fiscal = $2, codigo_postal = $3
WHERE entidad_id = $4
`, [data.rfc, '601', data.cp, entidadId]);
} else {
await client.query(`
INSERT INTO contribuyentes (entidad_id, rfc, regimen_fiscal, codigo_postal)
VALUES ($1, $2, $3, $4)
`, [entidadId, data.rfc, '601', data.cp]);
}
await client.query(`
INSERT INTO rfcs (rfc, razon_social, regimen_fiscal)
VALUES ($1, $2, $3)
ON CONFLICT (rfc) DO UPDATE SET razon_social = EXCLUDED.razon_social
`, [data.rfc, data.nombre, '601']);
await client.query('COMMIT');
return entidadId;
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}
async function crearCfdis(pool: Pool, contribuyenteId: string, rfcContribuyente: string, nombreContribuyente: string) {
const client = await pool.connect();
try {
await client.query('BEGIN');
// Asegurar RFCs de clientes/proveedores
const rfcs = new Map<string, number>();
for (const c of [...CLIENTES, ...PROVEEDORES]) {
const { rows: [r] } = await client.query<{ id: number }>(`
INSERT INTO rfcs (rfc, razon_social, regimen_fiscal)
VALUES ($1, $2, $3)
ON CONFLICT (rfc) DO UPDATE SET razon_social = EXCLUDED.razon_social
RETURNING id
`, [c.rfc, c.nombre, '601']);
rfcs.set(c.rfc, r.id);
}
const { rows: [rfcPrincipal] } = await client.query<{ id: number }>(`
INSERT INTO rfcs (rfc, razon_social, regimen_fiscal)
VALUES ($1, $2, $3)
ON CONFLICT (rfc) DO UPDATE SET razon_social = EXCLUDED.razon_social
RETURNING id
`, [rfcContribuyente, nombreContribuyente, '601']);
rfcs.set(rfcContribuyente, rfcPrincipal.id);
for (let i = 0; i < 10; i++) {
const esEmitido = i < 5;
const contraparte = esEmitido
? CLIENTES[i % CLIENTES.length]
: PROVEEDORES[i % PROVEEDORES.length];
const subtotal = Math.floor(Math.random() * 30000) + 1500;
const iva = Math.round(subtotal * 0.16 * 100) / 100;
const total = Math.round((subtotal + iva) * 100) / 100;
const daysAgo = Math.floor(Math.random() * 360);
const fecha = new Date();
fecha.setDate(fecha.getDate() - daysAgo);
fecha.setHours(9 + (i % 8), 0, 0, 0);
const year = String(fecha.getFullYear());
const month = String(fecha.getMonth() + 1).padStart(2, '0');
const fechaStr = fecha.toISOString();
const metodoPago = Math.random() > 0.4 ? 'PUE' : 'PPD';
const formasPago = ['01', '02', '03'];
const formaPago = formasPago[i % formasPago.length];
const usoCfdi = esEmitido ? 'G03' : 'G01';
const tipo = esEmitido ? 'EMITIDO' : 'RECIBIDO';
const rfcEmisor = esEmitido ? rfcContribuyente : contraparte.rfc;
const nombreEmisor = esEmitido ? nombreContribuyente : contraparte.nombre;
const rfcReceptor = esEmitido ? contraparte.rfc : rfcContribuyente;
const nombreReceptor = esEmitido ? contraparte.nombre : nombreContribuyente;
const { rows: [cfdi] } = await client.query<{ id: number }>(`
INSERT INTO cfdis (
year, month, type, uuid, serie, folio, status, fecha_emision,
rfc_emisor_id, rfc_emisor, nombre_emisor,
rfc_receptor_id, rfc_receptor, nombre_receptor,
subtotal, subtotal_mxn, descuento, descuento_mxn,
total, total_mxn, moneda, tipo_cambio, tipo_comprobante,
metodo_pago, forma_pago, uso_cfdi,
iva_traslado, iva_traslado_mxn,
regimen_fiscal_emisor, regimen_fiscal_receptor,
contribuyente_id, fecha_efectiva, meses_global, año_global
) 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,
$27, $28,
$29, $30,
$31, $32, $33, $34
) RETURNING id
`, [
year, month, tipo, randomUUID(), 'DEMO', String(2000 + i),
'Vigente', fechaStr,
rfcs.get(rfcEmisor), rfcEmisor, nombreEmisor,
rfcs.get(rfcReceptor), rfcReceptor, nombreReceptor,
subtotal, subtotal, 0, 0,
total, total, 'MXN', 1, 'I',
metodoPago, formaPago, usoCfdi,
iva, iva,
'601', '601',
contribuyenteId, fechaStr, month, year,
]);
const numConceptos = Math.floor(Math.random() * 2) + 1;
for (let j = 0; j < numConceptos; j++) {
const prod = PRODUCTOS[(i + j) % PRODUCTOS.length];
const cantidad = Math.floor(Math.random() * 4) + 1;
const valorUnitario = Math.floor(Math.random() * 3000) + 500;
const importe = Math.round(cantidad * valorUnitario * 100) / 100;
const ivaConcepto = Math.round(importe * 0.16 * 100) / 100;
await client.query(`
INSERT INTO cfdi_conceptos (
cfdi_id, clave_prod_serv, descripcion, cantidad, clave_unidad, unidad,
valor_unitario, valor_unitario_mxn, importe, importe_mxn,
iva_traslado, iva_traslado_mxn
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
`, [
cfdi.id, prod.clave, prod.descripcion, cantidad, 'E48', prod.unidad,
valorUnitario, valorUnitario, importe, importe,
ivaConcepto, ivaConcepto,
]);
}
}
await client.query('COMMIT');
console.log(` 📄 10 CFDIs creados para ${rfcContribuyente}`);
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}
main()
.catch((e) => {
console.error('\n❌ Error actualizando demo:', e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
await tenantDb.shutdown();
});

View File

@@ -45,6 +45,10 @@ import metricasRoutes from './routes/metricas.routes.js';
const app: Express = express(); const app: Express = express();
// Trust proxy — la app corre detrás de Cloudflare/nginx. Necesario para que
// express-rate-limit lea correctamente X-Forwarded-For sin lanzar warnings.
app.set('trust proxy', 1);
// Security. Helmet default incluye un CSP restrictivo que puede chocar con el // Security. Helmet default incluye un CSP restrictivo que puede chocar con el
// frontend cuando éste embebe recursos propios (ej: /terminos embebe el PDF de // 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 // /legal/). Dejamos CSP off en el API y centralizamos los headers de seguridad

View File

@@ -71,9 +71,9 @@ class TenantConnectionManager {
user: connectionOverride?.user ?? this.dbConfig.user, user: connectionOverride?.user ?? this.dbConfig.user,
password: connectionOverride?.password ?? this.dbConfig.password, password: connectionOverride?.password ?? this.dbConfig.password,
database: databaseName, database: databaseName,
max: 3, max: 10,
idleTimeoutMillis: 300_000, idleTimeoutMillis: 300_000,
connectionTimeoutMillis: 10_000, connectionTimeoutMillis: 30_000,
}; };
pool = new Pool(poolConfig); pool = new Pool(poolConfig);
@@ -187,11 +187,13 @@ class TenantConnectionManager {
} }
/** /**
* Remove idle pools (not accessed in last 5 minutes). * Remove idle pools (not accessed in last 12 hours).
* SAT syncs (initial/daily) can run for hours in background;
* a 5-minute timeout caused 'pool already ended' errors mid-sync.
*/ */
private cleanupIdlePools(): void { private cleanupIdlePools(): void {
const now = Date.now(); const now = Date.now();
const maxIdle = 5 * 60 * 1000; const maxIdle = 12 * 60 * 60 * 1000;
for (const [tenantId, entry] of this.pools.entries()) { for (const [tenantId, entry] of this.pools.entries()) {
if (now - entry.lastAccess.getTime() > maxIdle) { if (now - entry.lastAccess.getTime() > maxIdle) {

View File

@@ -2,53 +2,67 @@ export interface ObligacionFiscal {
id: string; id: string;
nombre: string; nombre: string;
fundamento: string; fundamento: string;
frecuencia: 'mensual' | 'bimestral' | 'trimestral' | 'anual' | 'eventual'; frecuencia: 'mensual' | 'bimestral' | 'trimestral' | 'cuatrimestral' | 'anual' | 'eventual';
fechaLimite: string; fechaLimite: string;
aplica: 'PM' | 'PF' | 'ambos'; aplica: 'PM' | 'PF' | 'ambos';
regimenes: string[] | null; // null = all regimes regimenes: string[] | null; // null = all regimes
condicion: string | null; condicion: string | null;
categoria: string; categoria: string;
recomendadaPorDefecto: boolean; recomendadaPorDefecto: boolean;
/** Si true, la obligación requiere comprobante de pago para cerrarse. */
requierePago: boolean;
} }
export const OBLIGACIONES_CATALOGO: ObligacionFiscal[] = [ export const OBLIGACIONES_CATALOGO: ObligacionFiscal[] = [
// === FEDERALES MENSUALES (día 17) === // === FEDERALES MENSUALES (día 17) ===
{ id: 'isr-provisional', nombre: 'Pago provisional de ISR', fundamento: 'Art. 14 LISR', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'ambos', regimenes: null, condicion: null, categoria: 'Federal mensual', recomendadaPorDefecto: true }, { id: 'isr-provisional', nombre: 'Pago provisional de ISR', fundamento: 'Art. 14 LISR', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'ambos', regimenes: null, condicion: null, categoria: 'Federal mensual', requierePago: true, recomendadaPorDefecto: true },
{ id: 'iva-mensual', nombre: 'Pago mensual definitivo de IVA', fundamento: 'Art. 5-D LIVA', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'ambos', regimenes: null, condicion: null, categoria: 'Federal mensual', recomendadaPorDefecto: true }, { id: 'iva-mensual', nombre: 'Pago mensual definitivo de IVA', fundamento: 'Art. 5-D LIVA', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'ambos', regimenes: null, condicion: null, categoria: 'Federal mensual', requierePago: true, recomendadaPorDefecto: true },
{ id: 'ret-isr-sueldos', nombre: 'Retenciones de ISR por sueldos y salarios', fundamento: 'Art. 96 LISR', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'PM', regimenes: null, condicion: 'Facturas emitidas tipo N', categoria: 'Federal mensual', recomendadaPorDefecto: false }, { id: 'actividades-vulnerables', nombre: 'Aviso de actividades vulnerables', fundamento: 'LFPIORPI', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'ambos', regimenes: null, condicion: null, categoria: 'Federal mensual', requierePago: false, recomendadaPorDefecto: false },
{ id: 'ret-isr-asimilados', nombre: 'Retenciones de ISR por asimilados a salarios', fundamento: 'Art. 94 LISR', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'PM', regimenes: null, condicion: 'Facturas emitidas tipo N', categoria: 'Federal mensual', recomendadaPorDefecto: false }, { id: 'ret-isr-sueldos', nombre: 'Retenciones de ISR por sueldos y salarios', fundamento: 'Art. 96 LISR', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'PM', regimenes: null, condicion: 'Facturas emitidas tipo N', categoria: 'Federal mensual', requierePago: false, recomendadaPorDefecto: false },
{ id: 'ret-isr-honorarios', nombre: 'Retenciones de ISR por honorarios y arrendamiento a PF', fundamento: 'Art. 106/116 LISR', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'PM', regimenes: null, condicion: 'PM que contrate PF', categoria: 'Federal mensual', recomendadaPorDefecto: false }, { id: 'ret-isr-asimilados', nombre: 'Retenciones de ISR por asimilados a salarios', fundamento: 'Art. 94 LISR', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'PM', regimenes: null, condicion: 'Facturas emitidas tipo N', categoria: 'Federal mensual', requierePago: false, recomendadaPorDefecto: false },
{ id: 'ret-iva', nombre: 'Retenciones de IVA (servicios, fletes, outsourcing)', fundamento: 'Art. 1-A LIVA', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'PM', regimenes: null, condicion: 'Según supuesto', categoria: 'Federal mensual', recomendadaPorDefecto: false }, { id: 'ret-isr-honorarios', nombre: 'Retenciones de ISR por honorarios y arrendamiento a PF', fundamento: 'Art. 106/116 LISR', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'PM', regimenes: null, condicion: 'PM que contrate PF', categoria: 'Federal mensual', requierePago: false, recomendadaPorDefecto: false },
{ id: 'ieps', nombre: 'Pago definitivo de IEPS', fundamento: 'Art. 5 LIEPS', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'ambos', regimenes: null, condicion: 'Productores/importadores', categoria: 'Federal mensual', recomendadaPorDefecto: false }, { id: 'ret-iva', nombre: 'Retenciones de IVA (servicios, fletes, outsourcing)', fundamento: 'Art. 1-A LIVA', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'PM', regimenes: null, condicion: 'Según supuesto', categoria: 'Federal mensual', requierePago: false, recomendadaPorDefecto: false },
{ id: 'ieps', nombre: 'Pago definitivo de IEPS', fundamento: 'Art. 5 LIEPS', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'ambos', regimenes: null, condicion: 'Productores/importadores', categoria: 'Federal mensual', requierePago: true, recomendadaPorDefecto: false },
// === INFORMATIVAS MENSUALES === // === INFORMATIVAS MENSUALES ===
{ id: 'diot', nombre: 'DIOT (Declaración Informativa de Operaciones con Terceros)', fundamento: 'Art. 32 LIVA', frecuencia: 'mensual', fechaLimite: 'Último día del mes siguiente', aplica: 'ambos', regimenes: null, condicion: 'PF con ingresos > $4M y todas las PM, excepto RESICO', categoria: 'Informativa mensual', recomendadaPorDefecto: false }, { id: 'diot', nombre: 'DIOT (Declaración Informativa de Operaciones con Terceros)', fundamento: 'Art. 32 LIVA', frecuencia: 'mensual', fechaLimite: 'Último día del mes siguiente', aplica: 'ambos', regimenes: null, condicion: 'PF con ingresos > $4M y todas las PM, excepto RESICO', categoria: 'Informativa mensual', requierePago: false, recomendadaPorDefecto: false },
{ id: 'cont-balanza', nombre: 'Contabilidad Electrónica - Balanza de comprobación', fundamento: 'CFF Art. 28', frecuencia: 'mensual', fechaLimite: 'Día 3 del segundo mes siguiente', aplica: 'ambos', regimenes: null, condicion: 'PF con ingresos > $4M y todas las PM, excepto RESICO', categoria: 'Informativa mensual', recomendadaPorDefecto: false }, { id: 'cont-balanza', nombre: 'Contabilidad Electrónica - Balanza de comprobación', fundamento: 'CFF Art. 28', frecuencia: 'mensual', fechaLimite: 'Día 3 del segundo mes siguiente', aplica: 'ambos', regimenes: null, condicion: 'PF con ingresos > $4M y todas las PM, excepto RESICO', categoria: 'Informativa mensual', requierePago: false, recomendadaPorDefecto: false },
{ id: 'cont-catalogo', nombre: 'Contabilidad Electrónica - Catálogo de cuentas', fundamento: 'CFF Art. 28', frecuencia: 'eventual', fechaLimite: 'Cuando haya modificación', aplica: 'ambos', regimenes: null, condicion: 'PF con ingresos > $4M y todas las PM, excepto RESICO', categoria: 'Informativa mensual', recomendadaPorDefecto: false }, { id: 'cont-catalogo', nombre: 'Contabilidad Electrónica - Catálogo de cuentas', fundamento: 'CFF Art. 28', frecuencia: 'eventual', fechaLimite: 'Cuando haya modificación', aplica: 'ambos', regimenes: null, condicion: 'PF con ingresos > $4M y todas las PM, excepto RESICO', categoria: 'Informativa mensual', requierePago: false, recomendadaPorDefecto: false },
// === FEDERALES TRIMESTRALES ===
{ id: 'ieps-trimestral', nombre: 'Declaración Informativa Múltiple del IEPS', fundamento: 'LIEPS', frecuencia: 'trimestral', fechaLimite: 'Día 17 de abril, julio, octubre y enero', aplica: 'ambos', regimenes: null, condicion: null, categoria: 'Federal trimestral', requierePago: false, recomendadaPorDefecto: false },
// === RESICO PM === // === RESICO PM ===
{ id: 'isr-resico-pm', nombre: 'Pago provisional ISR RESICO-PM', fundamento: 'Art. 206 LISR', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'PM', regimenes: ['626'], condicion: null, categoria: 'RESICO PM', recomendadaPorDefecto: true }, { id: 'isr-resico-pm', nombre: 'Pago provisional ISR RESICO-PM', fundamento: 'Art. 206 LISR', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'PM', regimenes: ['626'], condicion: null, categoria: 'RESICO PM', requierePago: true, recomendadaPorDefecto: true },
// === RESICO PF === // === RESICO PF ===
{ id: 'isr-resico-pf', nombre: 'Pago mensual ISR RESICO PF (1%-2.5%)', fundamento: 'Art. 113-E LISR', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'PF', regimenes: ['626'], condicion: null, categoria: 'RESICO PF', recomendadaPorDefecto: true }, { id: 'isr-resico-pf', nombre: 'Pago mensual ISR RESICO PF (1%-2.5%)', fundamento: 'Art. 113-E LISR', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'PF', regimenes: ['626'], condicion: null, categoria: 'RESICO PF', requierePago: true, recomendadaPorDefecto: true },
// === ANUALES PM === // === ANUALES PM ===
{ id: 'anual-isr-pm', nombre: 'Declaración Anual de ISR PM', fundamento: 'Art. 76 LISR', frecuencia: 'anual', fechaLimite: '31 de marzo', aplica: 'PM', regimenes: null, condicion: null, categoria: 'Anual', recomendadaPorDefecto: true }, { id: 'anual-isr-pm', nombre: 'Declaración Anual de ISR PM', fundamento: 'Art. 76 LISR', frecuencia: 'anual', fechaLimite: '31 de marzo', aplica: 'PM', regimenes: null, condicion: null, categoria: 'Anual', requierePago: true, recomendadaPorDefecto: true },
{ id: 'issif', nombre: 'ISSIF (Información sobre Situación Fiscal)', fundamento: 'CFF Art. 32-H', frecuencia: 'anual', fechaLimite: 'Con la declaración anual', aplica: 'PM', regimenes: null, condicion: null, categoria: 'Anual', recomendadaPorDefecto: false }, { id: 'declaracion-transparencia', nombre: 'Declaración Informativa de transparencia', fundamento: 'LFTAIPG', frecuencia: 'anual', fechaLimite: 'Día 31 de mayo', aplica: 'PM', regimenes: null, condicion: null, categoria: 'Federal anual', requierePago: false, recomendadaPorDefecto: false },
{ id: 'dictamen-fiscal', nombre: 'Dictamen Fiscal', fundamento: 'CFF Art. 32-A', frecuencia: 'anual', fechaLimite: '15 de mayo', aplica: 'PM', regimenes: null, condicion: 'Ingresos > $1,855M o grupos', categoria: 'Anual', recomendadaPorDefecto: false }, { id: 'issif', nombre: 'ISSIF (Información sobre Situación Fiscal)', fundamento: 'CFF Art. 32-H', frecuencia: 'anual', fechaLimite: 'Con la declaración anual', aplica: 'PM', regimenes: null, condicion: null, categoria: 'Anual', requierePago: false, recomendadaPorDefecto: false },
{ id: 'dim', nombre: 'DIM - Declaraciones Informativas Múltiples', fundamento: 'CFF', frecuencia: 'anual', fechaLimite: '15 de febrero', aplica: 'PM', regimenes: null, condicion: null, categoria: 'Anual', recomendadaPorDefecto: false }, { id: 'dictamen-fiscal', nombre: 'Dictamen Fiscal', fundamento: 'CFF Art. 32-A', frecuencia: 'anual', fechaLimite: '15 de mayo', aplica: 'PM', regimenes: null, condicion: 'Ingresos > $1,855M o grupos', categoria: 'Anual', requierePago: false, recomendadaPorDefecto: false },
{ id: 'dim', nombre: 'DIM - Declaraciones Informativas Múltiples', fundamento: 'CFF', frecuencia: 'anual', fechaLimite: '15 de febrero', aplica: 'PM', regimenes: null, condicion: null, categoria: 'Anual', requierePago: false, recomendadaPorDefecto: false },
// === ANUALES PF === // === ANUALES PF ===
{ id: 'anual-isr-pf', nombre: 'Declaración Anual PF', fundamento: 'Art. 150 LISR', frecuencia: 'anual', fechaLimite: '30 de abril', aplica: 'PF', regimenes: null, condicion: null, categoria: 'Anual', recomendadaPorDefecto: true }, { id: 'anual-isr-pf', nombre: 'Declaración Anual PF', fundamento: 'Art. 150 LISR', frecuencia: 'anual', fechaLimite: '30 de abril', aplica: 'PF', regimenes: null, condicion: null, categoria: 'Anual', requierePago: true, recomendadaPorDefecto: true },
// === SEGURIDAD SOCIAL === // === SEGURIDAD SOCIAL ===
{ id: 'imss-cuotas', nombre: 'Cuotas obrero-patronales IMSS', fundamento: 'LSS', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'ambos', regimenes: null, condicion: 'Con empleados', categoria: 'Seguridad social', recomendadaPorDefecto: false }, { id: 'imss-cuotas', nombre: 'Cuotas obrero-patronales IMSS', fundamento: 'LSS', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'ambos', regimenes: null, condicion: 'Con empleados', categoria: 'Seguridad social', requierePago: true, recomendadaPorDefecto: false },
{ id: 'infonavit', nombre: 'Aportaciones INFONAVIT + amortizaciones', fundamento: 'LINFONAVIT', frecuencia: 'bimestral', fechaLimite: 'Día 17 del mes siguiente al bimestre', aplica: 'ambos', regimenes: null, condicion: 'Con empleados', categoria: 'Seguridad social', recomendadaPorDefecto: false }, { id: 'sipare', nombre: 'SIPARE - Cuotas obrero-patronales', fundamento: 'LSS', frecuencia: 'mensual', fechaLimite: 'Día 15 del mes siguiente', aplica: 'ambos', regimenes: null, condicion: 'Con empleados', categoria: 'Seguridad social', requierePago: true, recomendadaPorDefecto: false },
{ id: 'sar-retiro', nombre: 'SAR / Retiro', fundamento: 'LSS', frecuencia: 'bimestral', fechaLimite: 'Día 17 del mes siguiente al bimestre', aplica: 'ambos', regimenes: null, condicion: 'Con empleados', categoria: 'Seguridad social', recomendadaPorDefecto: false }, { id: 'infonavit', nombre: 'Aportaciones INFONAVIT + amortizaciones', fundamento: 'LINFONAVIT', frecuencia: 'bimestral', fechaLimite: 'Día 17 del mes siguiente al bimestre', aplica: 'ambos', regimenes: null, condicion: 'Con empleados', categoria: 'Seguridad social', requierePago: true, recomendadaPorDefecto: false },
{ id: 'prima-riesgo', nombre: 'Determinación Prima de Riesgo de Trabajo', fundamento: 'LSS Art. 74', frecuencia: 'anual', fechaLimite: 'Febrero', aplica: 'ambos', regimenes: null, condicion: 'Con empleados', categoria: 'Seguridad social', recomendadaPorDefecto: false }, { id: 'sar-retiro', nombre: 'SAR / Retiro', fundamento: 'LSS', frecuencia: 'bimestral', fechaLimite: 'Día 17 del mes siguiente al bimestre', aplica: 'ambos', regimenes: null, condicion: 'Con empleados', categoria: 'Seguridad social', requierePago: true, recomendadaPorDefecto: false },
{ id: 'sisub', nombre: 'Sistema de Información de Subcontratación', fundamento: 'LFT', frecuencia: 'cuatrimestral', fechaLimite: 'Día 17 de enero, mayo y septiembre', aplica: 'ambos', regimenes: null, condicion: 'Con empleados', categoria: 'Seguridad social', requierePago: false, recomendadaPorDefecto: false },
{ id: 'prima-riesgo', nombre: 'Determinación Prima de Riesgo de Trabajo', fundamento: 'LSS Art. 74', frecuencia: 'anual', fechaLimite: 'Febrero', aplica: 'ambos', regimenes: null, condicion: 'Con empleados', categoria: 'Seguridad social', requierePago: true, recomendadaPorDefecto: false },
// === CRÉDITOS DE LOS TRABAJADORES ===
{ id: 'fonacot', nombre: 'Crédito FONACOT', fundamento: 'Ley FONACOT', frecuencia: 'mensual', fechaLimite: 'Día 5 del mes siguiente', aplica: 'ambos', regimenes: null, condicion: 'Con empleados', categoria: 'Créditos de los trabajadores', requierePago: true, recomendadaPorDefecto: false },
// === ESTATALES === // === ESTATALES ===
{ id: 'isn', nombre: 'ISN - Impuesto Sobre Nómina', fundamento: 'Ley estatal', frecuencia: 'mensual', fechaLimite: 'Varía por estado (CDMX día 17)', aplica: 'ambos', regimenes: null, condicion: 'Con empleados', categoria: 'Estatal', recomendadaPorDefecto: false }, { id: 'isn', nombre: 'ISN - Impuesto Sobre Nómina', fundamento: 'Ley estatal', frecuencia: 'mensual', fechaLimite: 'Varía por estado (CDMX día 17)', aplica: 'ambos', regimenes: null, condicion: 'Con empleados', categoria: 'Estatal', requierePago: true, recomendadaPorDefecto: false },
{ id: 'isrtp', nombre: 'Impuesto sobre remuneración al trabajo', fundamento: 'Ley estatal', frecuencia: 'mensual', fechaLimite: 'Día 10 del mes siguiente', aplica: 'ambos', regimenes: null, condicion: null, categoria: 'Estatal', requierePago: true, recomendadaPorDefecto: false },
{ id: 'ish', nombre: 'ISH - Impuesto Sobre Hospedaje', fundamento: 'Ley estatal', frecuencia: 'mensual', fechaLimite: 'Día 15 del mes siguiente', aplica: 'ambos', regimenes: null, condicion: null, categoria: 'Estatal', requierePago: true, recomendadaPorDefecto: false },
]; ];
/** /**

View File

@@ -125,7 +125,9 @@ export async function resolverAlertaManual(req: Request, res: Response, next: Ne
export async function getAlertasAutomaticas(req: Request, res: Response, next: NextFunction) { export async function getAlertasAutomaticas(req: Request, res: Response, next: NextFunction) {
try { try {
const contribuyenteId = req.query.contribuyenteId as string | undefined; const contribuyenteId = req.query.contribuyenteId as string | undefined;
console.log(`[AlertasCtrl] GET /automaticas tenant=${req.user!.tenantId} contribuyente=${contribuyenteId || 'null'} user=${req.user!.userId} role=${req.user!.role}`);
const alertas = await generarAlertasAutomaticas(req.tenantPool!, req.user!.tenantId, contribuyenteId || null); const alertas = await generarAlertasAutomaticas(req.tenantPool!, req.user!.tenantId, contribuyenteId || null);
console.log(`[AlertasCtrl] GET /automaticas devuelve ${alertas.length} alertas: ${alertas.map(a => a.id).join(', ') || 'ninguna'}`);
res.json(alertas); res.json(alertas);
} catch (error) { } catch (error) {
next(error); next(error);
@@ -333,7 +335,7 @@ export async function getCancelados(req: Request, res: Response, next: NextFunct
total_mxn as "totalMxn", fecha_cancelacion as "fechaCancelacion" total_mxn as "totalMxn", fecha_cancelacion as "fechaCancelacion"
FROM cfdis FROM cfdis
WHERE status IN ('Cancelado', '0') WHERE status IN ('Cancelado', '0')
AND fecha_emision >= $1::date AND (fecha_emision - interval '1 hour') >= $1::date
${cf} ${cf}
ORDER BY fecha_emision DESC ORDER BY fecha_emision DESC
`, [hace5.toISOString().split('T')[0]]); `, [hace5.toISOString().split('T')[0]]);
@@ -364,7 +366,7 @@ export async function getCancelacionesPeriodoAnterior(req: Request, res: Respons
FROM cfdis FROM cfdis
WHERE status IN ('Cancelado', '0') WHERE status IN ('Cancelado', '0')
AND fecha_cancelacion >= $1::date AND fecha_cancelacion >= $1::date
AND fecha_emision < $1::date AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') < $1::date
${cf} ${cf}
ORDER BY fecha_cancelacion DESC ORDER BY fecha_cancelacion DESC
`, [inicioMes]); `, [inicioMes]);

View File

@@ -0,0 +1,197 @@
import type { Request, Response, NextFunction } from 'express';
import { z } from 'zod';
import * as asignacionesService from '../services/asignaciones.service.js';
import { getEntidadesVisibles } from '../utils/entidades-visibles.js';
import { AppError } from '../middlewares/error.middleware.js';
/**
* Valida que el auxiliar pertenezca al supervisor (o que el caller sea owner).
* Owner puede asignar a cualquier auxiliar del tenant.
* La relación se infiere desde carteras (directas y subcarteras) con fallback
* a la tabla legacy auxiliar_supervisores.
*/
async function validarAuxiliarDelSupervisor(
pool: import('pg').Pool,
supervisorUserId: string,
auxiliarUserId: string,
callerRole: string,
): Promise<void> {
if (callerRole === 'owner') return;
const { rows } = await pool.query(
`SELECT 1 FROM (
SELECT c.auxiliar_user_id
FROM carteras c
WHERE c.supervisor_user_id = $1
AND c.auxiliar_user_id = $2
UNION
SELECT sub.auxiliar_user_id
FROM carteras sub
JOIN carteras p ON p.id = sub.parent_id
WHERE p.supervisor_user_id = $1
AND sub.auxiliar_user_id = $2
UNION
SELECT auxiliar_user_id FROM auxiliar_supervisores
WHERE supervisor_user_id = $1 AND auxiliar_user_id = $2
) t LIMIT 1`,
[supervisorUserId, auxiliarUserId],
);
if (rows.length === 0) {
throw new AppError(403, 'El auxiliar no pertenece a tu equipo');
}
}
/**
* Valida que el auxiliar tenga al contribuyente en alguna de sus subcarteras.
* Si no hay ningún auxiliar con ese contribuyente en su subcartera, la asignación
* se rechaza (el supervisor debe agregar el contribuyente a una subcartera primero).
*/
async function validarAuxiliarEnSubcartera(
pool: import('pg').Pool,
contribuyenteId: string,
auxiliarUserId: string,
): Promise<void> {
const elegibles = await asignacionesService.getAuxiliaresElegibles(pool, contribuyenteId);
if (elegibles.length === 0) {
throw new AppError(403, 'Ningún auxiliar tiene este contribuyente en su subcartera');
}
if (!elegibles.includes(auxiliarUserId)) {
throw new AppError(403, 'El auxiliar no tiene este contribuyente en ninguna de sus subcarteras');
}
}
// ── Obligaciones ──
export async function asignarObligacion(req: Request, res: Response, next: NextFunction) {
try {
const contribuyenteId = String(req.params.id);
const obligacionId = String(req.params.obligacionId);
const schema = z.object({ auxiliarUserId: z.string().uuid() });
const { auxiliarUserId } = schema.parse(req.body);
await validarAuxiliarDelSupervisor(
req.tenantPool!,
req.user!.userId,
auxiliarUserId,
req.user!.role,
);
await validarAuxiliarEnSubcartera(
req.tenantPool!,
contribuyenteId,
auxiliarUserId,
);
await asignacionesService.asignarObligacion(
req.tenantPool!,
obligacionId,
auxiliarUserId,
req.user!.userId,
);
res.json({ message: 'Obligación asignada' });
} catch (err: any) {
if (err instanceof z.ZodError) return next(new AppError(400, err.errors[0].message));
next(err);
}
}
export async function desasignarObligacion(req: Request, res: Response, next: NextFunction) {
try {
const obligacionId = String(req.params.obligacionId);
await asignacionesService.desasignarObligacion(req.tenantPool!, obligacionId);
res.json({ message: 'Asignación de obligación eliminada' });
} catch (err) { next(err); }
}
// ── Tareas ──
export async function asignarTarea(req: Request, res: Response, next: NextFunction) {
try {
const tareaId = String(req.params.id);
const schema = z.object({ auxiliarUserId: z.string().uuid() });
const { auxiliarUserId } = schema.parse(req.body);
await validarAuxiliarDelSupervisor(
req.tenantPool!,
req.user!.userId,
auxiliarUserId,
req.user!.role,
);
// Obtener contribuyenteId de la tarea para validar subcartera
const { rows } = await req.tenantPool!.query<{ contribuyente_id: string }>(
`SELECT contribuyente_id FROM tareas_catalogo WHERE id = $1 LIMIT 1`,
[tareaId],
);
if (rows.length > 0) {
await validarAuxiliarEnSubcartera(
req.tenantPool!,
rows[0].contribuyente_id,
auxiliarUserId,
);
}
await asignacionesService.asignarTarea(
req.tenantPool!,
tareaId,
auxiliarUserId,
req.user!.userId,
);
res.json({ message: 'Tarea asignada' });
} catch (err: any) {
if (err instanceof z.ZodError) return next(new AppError(400, err.errors[0].message));
next(err);
}
}
export async function desasignarTarea(req: Request, res: Response, next: NextFunction) {
try {
const tareaId = String(req.params.id);
await asignacionesService.desasignarTarea(req.tenantPool!, tareaId);
res.json({ message: 'Asignación de tarea eliminada' });
} catch (err) { next(err); }
}
// ── Listados ──
export async function listPorSupervisor(req: Request, res: Response, next: NextFunction) {
try {
const data = await asignacionesService.getAsignacionesPorSupervisor(
req.tenantPool!,
req.user!.userId,
req.user!.role,
);
res.json(data);
} catch (err) { next(err); }
}
export async function listPorAuxiliar(req: Request, res: Response, next: NextFunction) {
try {
const data = await asignacionesService.getAsignacionesPorAuxiliar(
req.tenantPool!,
req.user!.userId,
);
res.json(data);
} catch (err) { next(err); }
}
export async function listSinAsignar(req: Request, res: Response, next: NextFunction) {
try {
const entidadIds = await getEntidadesVisibles(req.tenantPool!, req.user!.userId, req.user!.role);
const [obligaciones, tareas] = await Promise.all([
asignacionesService.getObligacionesSinAsignar(req.tenantPool!, entidadIds),
asignacionesService.getTareasSinAsignar(req.tenantPool!, entidadIds),
]);
res.json({ obligaciones, tareas });
} catch (err) { next(err); }
}
export async function listAuxiliaresElegibles(req: Request, res: Response, next: NextFunction) {
try {
const contribuyenteId = String(req.params.contribuyenteId);
const auxIds = await asignacionesService.getAuxiliaresElegibles(req.tenantPool!, contribuyenteId);
res.json({ auxiliares: auxIds });
} catch (err) { next(err); }
}

View File

@@ -36,6 +36,10 @@ export async function getClavesUnidad(req: Request, res: Response, next: NextFun
} catch (error) { next(error); } } catch (error) { next(error); }
} }
function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
export async function searchClaveProdServ(req: Request, res: Response, next: NextFunction) { export async function searchClaveProdServ(req: Request, res: Response, next: NextFunction) {
try { try {
const q = (req.query.q as string || '').trim(); const q = (req.query.q as string || '').trim();
@@ -44,11 +48,10 @@ export async function searchClaveProdServ(req: Request, res: Response, next: Nex
} }
// Buscar por clave o descripción // Buscar por clave o descripción
// Primero buscar por clave, luego por texto
const data = await prisma.catClaveProdServ.findMany({ const data = await prisma.catClaveProdServ.findMany({
where: { where: {
OR: [ OR: [
{ clave: { startsWith: q } }, { clave: { startsWith: q, mode: 'insensitive' } },
{ descripcion: { contains: q, mode: 'insensitive' } }, { descripcion: { contains: q, mode: 'insensitive' } },
], ],
}, },
@@ -68,8 +71,8 @@ export async function searchClaveProdServ(req: Request, res: Response, next: Nex
return res.json(fallback); return res.json(fallback);
} }
// Buscar con variantes comunes de acentos // Buscar con variantes comunes de acentos, escapando caracteres regex primero
const withAccents = normalized const withAccents = escapeRegex(normalized)
.replace(/a/gi, '[aá]').replace(/e/gi, '[eé]') .replace(/a/gi, '[aá]').replace(/e/gi, '[eé]')
.replace(/i/gi, '[ií]').replace(/o/gi, '[oó]').replace(/u/gi, '[uú]') .replace(/i/gi, '[ií]').replace(/o/gi, '[oó]').replace(/u/gi, '[uú]')
.replace(/n/gi, '[nñ]'); .replace(/n/gi, '[nñ]');

View File

@@ -1,6 +1,7 @@
import type { Request, Response, NextFunction } from 'express'; import type { Request, Response, NextFunction } from 'express';
import * as cfdiService from '../services/cfdi.service.js'; import * as cfdiService from '../services/cfdi.service.js';
import { AppError } from '../middlewares/error.middleware.js'; import { AppError } from '../middlewares/error.middleware.js';
import AdmZip from 'adm-zip';
import { GRUPO_PF_EMPRESARIAL, GRUPO_PM_OTROS } from '../services/dashboard.service.js'; import { GRUPO_PF_EMPRESARIAL, GRUPO_PM_OTROS } from '../services/dashboard.service.js';
import { getRegimenesIgnoradosClaves } from '../services/regimen.service.js'; import { getRegimenesIgnoradosClaves } from '../services/regimen.service.js';
import { resolveContribuyenteContext } from '../utils/contribuyente-context.js'; import { resolveContribuyenteContext } from '../utils/contribuyente-context.js';
@@ -75,6 +76,50 @@ export async function getXml(req: Request, res: Response, next: NextFunction) {
} }
} }
export async function downloadXmlsZip(req: Request, res: Response, next: NextFunction) {
try {
if (!req.tenantPool) {
return next(new AppError(400, 'Tenant no configurado'));
}
const filters: CfdiFilters = {
tipo: req.body.tipo as any,
tipoComprobante: req.body.tipoComprobante as any,
estado: req.body.estado as any,
fechaInicio: req.body.fechaInicio as string,
fechaFin: req.body.fechaFin as string,
rfc: req.body.rfc as string,
emisor: req.body.emisor as string,
receptor: req.body.receptor as string,
search: req.body.search as string,
contribuyenteId: req.body.contribuyenteId as string,
};
const cfdis = await cfdiService.getCfdiXmlsForZip(req.tenantPool, filters);
const zip = new AdmZip();
let added = 0;
for (const cfdi of cfdis) {
if (cfdi.xml) {
const filename = `${cfdi.uuid || 'cfdi'}.xml`;
zip.addFile(filename, Buffer.from(cfdi.xml, 'utf8'));
added++;
}
}
if (added === 0) {
return next(new AppError(404, 'No se encontraron XMLs para los filtros aplicados'));
}
const zipBuffer = zip.toBuffer();
res.set('Content-Type', 'application/zip');
res.set('Content-Disposition', `attachment; filename="cfdis-${Date.now()}.zip"`);
res.send(zipBuffer);
} catch (error) {
next(error);
}
}
export async function listConceptos(req: Request, res: Response, next: NextFunction) { export async function listConceptos(req: Request, res: Response, next: NextFunction) {
try { try {
if (!req.tenantPool) return next(new AppError(400, 'Tenant no configurado')); if (!req.tenantPool) return next(new AppError(400, 'Tenant no configurado'));
@@ -239,13 +284,28 @@ export async function drillDown(req: Request, res: Response, next: NextFunction)
) ${NO_IGNORADO_EMISOR.replace('regimen_fiscal_emisor', `CASE WHEN ${esEmisor} THEN regimen_fiscal_emisor ELSE regimen_fiscal_receptor END`)}`; ) ${NO_IGNORADO_EMISOR.replace('regimen_fiscal_emisor', `CASE WHEN ${esEmisor} THEN regimen_fiscal_emisor ELSE regimen_fiscal_receptor END`)}`;
} else if (bucketStr === 'gastos') { } else if (bucketStr === 'gastos') {
// Las E PUE se exhiben en su propia card "NCs Recibidas" — no entran aquí. // Las E PUE se exhiben en su propia card "NCs Recibidas" — no entran aquí.
// La nómina emitida (tipo_comprobante = 'N') SÍ entra: el patrón la emite
// (lado emisor) y es un gasto/egreso para sus libros — alineado con
// calcularEgresosPorRegimen en dashboard.service.ts.
where += ` AND ( where += ` AND (
${esReceptor} AND ( (
(tipo_comprobante = 'I' AND metodo_pago = 'PUE') ${esReceptor} AND (
OR (tipo_comprobante = 'P') (tipo_comprobante = 'I' AND metodo_pago = 'PUE')
OR (tipo_comprobante = 'P')
)
AND regimen_fiscal_receptor IN (${TODOS_REGS})
) )
AND regimen_fiscal_receptor IN (${TODOS_REGS}) OR (
) ${NO_IGNORADO_RECEPTOR}`; ${esEmisor} AND tipo_comprobante = 'N'
AND regimen_fiscal_emisor IN (${TODOS_REGS})
)
)`;
if (ignorados.length > 0) {
where += ` AND (
(${esReceptor} AND regimen_fiscal_receptor NOT IN (${ignorados.map(r => `'${r}'`).join(',')}))
OR (${esEmisor} AND tipo_comprobante = 'N' AND regimen_fiscal_emisor NOT IN (${ignorados.map(r => `'${r}'`).join(',')}))
)`;
}
} else if (bucketStr === 'causado') { } else if (bucketStr === 'causado') {
where += ` AND ( where += ` AND (
${esEmisor} AND ( ${esEmisor} AND (

View File

@@ -9,7 +9,7 @@ export async function createInvitation(req: Request, res: Response, next: NextFu
return res.status(400).json({ message: 'El email es requerido' }); return res.status(400).json({ message: 'El email es requerido' });
} }
// Solo platform_admin puede crear invitaciones // Solo platform_admin puede crear invitaciones de cliente
const isAdmin = await hasAnyPlatformRole(req.user!.userId, 'platform_admin'); const isAdmin = await hasAnyPlatformRole(req.user!.userId, 'platform_admin');
if (!isAdmin) { if (!isAdmin) {
return res.status(403).json({ message: 'Solo administradores pueden crear invitaciones' }); return res.status(403).json({ message: 'Solo administradores pueden crear invitaciones' });

View File

@@ -13,7 +13,7 @@ export async function uploadFiel(req: Request, res: Response, next: NextFunction
return next(new AppError(400, 'cerFile, keyFile y password son requeridos')); return next(new AppError(400, 'cerFile, keyFile y password son requeridos'));
} }
const contribuyenteId = String(req.params.id); const contribuyenteId = String(req.params.id);
const contrib = await getContribuyenteById(req.tenantPool!, contribuyenteId); const contrib = await getContribuyenteById(req.tenantPool!, contribuyenteId, req.user!.tenantId);
if (!contrib) return next(new AppError(404, 'Contribuyente no encontrado')); if (!contrib) return next(new AppError(404, 'Contribuyente no encontrado'));
const result = await fielService.uploadFielContribuyente(req.tenantPool!, contribuyenteId, cerFile, keyFile, password); const result = await fielService.uploadFielContribuyente(req.tenantPool!, contribuyenteId, cerFile, keyFile, password);
@@ -62,7 +62,7 @@ export async function deleteFiel(req: Request, res: Response, next: NextFunction
export async function createOrg(req: Request, res: Response, next: NextFunction) { export async function createOrg(req: Request, res: Response, next: NextFunction) {
try { try {
const contribuyenteId = String(req.params.id); const contribuyenteId = String(req.params.id);
const contrib = await getContribuyenteById(req.tenantPool!, contribuyenteId); const contrib = await getContribuyenteById(req.tenantPool!, contribuyenteId, req.user!.tenantId);
if (!contrib) return next(new AppError(404, 'Contribuyente no encontrado')); if (!contrib) return next(new AppError(404, 'Contribuyente no encontrado'));
const result = await facturapiService.createOrgContribuyente(req.tenantPool!, contribuyenteId, contrib.nombre); const result = await facturapiService.createOrgContribuyente(req.tenantPool!, contribuyenteId, contrib.nombre);

View File

@@ -1,6 +1,7 @@
import type { Request, Response, NextFunction } from 'express'; import type { Request, Response, NextFunction } from 'express';
import { z } from 'zod'; import { z } from 'zod';
import * as contribuyenteService from '../services/contribuyente.service.js'; import * as contribuyenteService from '../services/contribuyente.service.js';
import * as carteraService from '../services/cartera.service.js';
import { AppError } from '../middlewares/error.middleware.js'; import { AppError } from '../middlewares/error.middleware.js';
import { getEntidadesVisibles } from '../utils/entidades-visibles.js'; import { getEntidadesVisibles } from '../utils/entidades-visibles.js';
import { adjustDespachoOverage } from '../services/payment/addon.service.js'; import { adjustDespachoOverage } from '../services/payment/addon.service.js';
@@ -40,14 +41,31 @@ const updateSchema = createSchema.partial();
export async function list(req: Request, res: Response, next: NextFunction) { export async function list(req: Request, res: Response, next: NextFunction) {
try { try {
const visibleIds = await getEntidadesVisibles(req.tenantPool!, req.user!.userId, req.user!.role); const visibleIds = await getEntidadesVisibles(req.tenantPool!, req.user!.userId, req.user!.role);
const rows = await contribuyenteService.listContribuyentes(req.tenantPool!, visibleIds); const rows = await contribuyenteService.listContribuyentes(req.tenantPool!, visibleIds, req.user!.tenantId);
return res.json({ data: rows });
// Batch lookup de nombres de supervisores
const supervisorIds = [...new Set(rows.map(r => r.supervisorUserId).filter(Boolean))] as string[];
const supervisorNames: Record<string, string> = {};
if (supervisorIds.length > 0) {
const users = await prisma.user.findMany({
where: { id: { in: supervisorIds } },
select: { id: true, nombre: true },
});
for (const u of users) supervisorNames[u.id] = u.nombre;
}
return res.json({
data: rows.map(r => ({
...r,
supervisorNombre: r.supervisorUserId ? (supervisorNames[r.supervisorUserId] ?? null) : null,
})),
});
} catch (err) { return next(err); } } catch (err) { return next(err); }
} }
export async function getById(req: Request, res: Response, next: NextFunction) { export async function getById(req: Request, res: Response, next: NextFunction) {
try { try {
const row = await contribuyenteService.getContribuyenteById(req.tenantPool!, String(req.params.id)); const row = await contribuyenteService.getContribuyenteById(req.tenantPool!, String(req.params.id), req.user!.tenantId);
if (!row) return next(new AppError(404, 'Contribuyente no encontrado')); if (!row) return next(new AppError(404, 'Contribuyente no encontrado'));
return res.json(row); return res.json(row);
} catch (err) { return next(err); } } catch (err) { return next(err); }
@@ -77,6 +95,19 @@ export async function create(req: Request, res: Response, next: NextFunction) {
const row = await contribuyenteService.createContribuyente(req.tenantPool!, data); const row = await contribuyenteService.createContribuyente(req.tenantPool!, data);
// Si se asignó un supervisor, agregar el contribuyente a todas las carteras
// top-level de ese supervisor para que aparezca directamente en su vista.
if (data.supervisorUserId) {
try {
const carteras = await carteraService.listCarteras(req.tenantPool!, data.supervisorUserId);
await Promise.all(
carteras.map(c => carteraService.addEntidadToCartera(req.tenantPool!, c.id, row.id))
);
} catch (err: any) {
console.error('[Contribuyente] Auto-assign to cartera failed (non-blocking):', err.message || err);
}
}
// Ajuste de overage despacho: si el tenant pasa de 100 a 101+ RFCs, crea // Ajuste de overage despacho: si el tenant pasa de 100 a 101+ RFCs, crea
// el addon y devuelve paymentUrl para que el frontend redirija al usuario. // el addon y devuelve paymentUrl para que el frontend redirija al usuario.
// Fail-soft: si falla el addon, el contribuyente queda creado y se loguea. // Fail-soft: si falla el addon, el contribuyente queda creado y se loguea.
@@ -139,6 +170,15 @@ export async function addClienteAcceso(req: Request, res: Response, next: NextFu
const { userId } = req.body; const { userId } = req.body;
if (!userId || typeof userId !== 'string') return next(new AppError(400, 'userId requerido')); if (!userId || typeof userId !== 'string') return next(new AppError(400, 'userId requerido'));
const entidadId = String(req.params.id); const entidadId = String(req.params.id);
// Seguridad: supervisor solo puede asignar contribuyentes que supervise
if (req.user!.role === 'supervisor') {
const visibleIds = await getEntidadesVisibles(req.tenantPool!, req.user!.userId, req.user!.role);
if (!visibleIds.includes(entidadId)) {
return next(new AppError(403, 'No tienes acceso a este contribuyente'));
}
}
await req.tenantPool!.query( await req.tenantPool!.query(
'INSERT INTO cliente_accesos (user_id, entidad_id) VALUES ($1, $2) ON CONFLICT DO NOTHING', 'INSERT INTO cliente_accesos (user_id, entidad_id) VALUES ($1, $2) ON CONFLICT DO NOTHING',
[userId, entidadId], [userId, entidadId],

View File

@@ -3,6 +3,7 @@ import { z } from 'zod';
import { signupDespacho } from '../services/despacho.service.js'; import { signupDespacho } from '../services/despacho.service.js';
import { AppError } from '../middlewares/error.middleware.js'; import { AppError } from '../middlewares/error.middleware.js';
import { prisma } from '../config/database.js'; import { prisma } from '../config/database.js';
import { getPlanPrice } from '../services/payment/subscription.service.js';
const signupSchema = z.object({ const signupSchema = z.object({
despacho: z.object({ despacho: z.object({
@@ -47,7 +48,7 @@ export async function getMyPlan(req: Request, res: Response, next: NextFunction)
// business_control desde una TrialInvitation), respetamos ese plan // business_control desde una TrialInvitation), respetamos ese plan
// para que el feature-gate y los límites funcionen correctamente. // para que el feature-gate y los límites funcionen correctamente.
const subscription = await prisma.subscription.findFirst({ const subscription = await prisma.subscription.findFirst({
where: { tenantId, status: { in: ['authorized', 'pending', 'paused', 'trial'] } }, where: { tenantId, status: { in: ['authorized', 'pending', 'paused', 'trial', 'trial_expired'] } },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
select: { select: {
status: true, amount: true, plan: true, status: true, amount: true, plan: true,
@@ -64,6 +65,18 @@ export async function getMyPlan(req: Request, res: Response, next: NextFunction)
currentPlan = String(tenant.plan); currentPlan = String(tenant.plan);
} }
// Precio de catálogo del plan actual (primer año, anual). La UI lo usa
// cuando la suscripción aún no tiene monto (trial/trial_expired) para
// mostrar el CTA de pago.
let planPrice: number | null = null;
if (currentPlan && currentPlan !== 'trial' && currentPlan !== 'custom') {
try {
planPrice = await getPlanPrice(currentPlan as any, 'annual', 'firstYear');
} catch {
planPrice = null;
}
}
// Estado de suscripción activa (si hay) — alimenta la UI con el monto // Estado de suscripción activa (si hay) — alimenta la UI con el monto
// recurrente actual, fecha de próxima renovación y si el primer pago // recurrente actual, fecha de próxima renovación y si el primer pago
// (cuando aplica dualidad firstYear) ya fue completado. // (cuando aplica dualidad firstYear) ya fue completado.
@@ -72,6 +85,7 @@ export async function getMyPlan(req: Request, res: Response, next: NextFunction)
dbMode: tenant.dbMode, dbMode: tenant.dbMode,
trialEndsAt: tenant.trialEndsAt?.toISOString() ?? null, trialEndsAt: tenant.trialEndsAt?.toISOString() ?? null,
isTrialActive, isTrialActive,
planPrice,
subscription: subscription subscription: subscription
? { ? {
status: subscription.status, status: subscription.status,

View File

@@ -4,6 +4,7 @@ import { getOpiniones, getOpinionPdf, consultarOpinion, consultarOpinionContribu
import * as declaracionesService from '../services/declaraciones.service.js'; import * as declaracionesService from '../services/declaraciones.service.js';
import * as constanciaService from '../services/constancia.service.js'; import * as constanciaService from '../services/constancia.service.js';
import * as extrasService from '../services/documentos-extras.service.js'; import * as extrasService from '../services/documentos-extras.service.js';
import * as obligacionEvidenciasService from '../services/obligacion-evidencias.service.js';
import { notifyDocumentoSubido } from '../services/notify-upload.service.js'; import { notifyDocumentoSubido } from '../services/notify-upload.service.js';
import { AppError } from '../middlewares/error.middleware.js'; import { AppError } from '../middlewares/error.middleware.js';
@@ -71,7 +72,7 @@ export async function consultarManual(req: Request, res: Response, next: NextFun
// Declaraciones provisionales // Declaraciones provisionales
// ============================================================================ // ============================================================================
const ROLES_UPLOAD = ['owner', 'cfo', 'contador', 'auxiliar']; const ROLES_UPLOAD = ['owner', 'cfo', 'contador', 'auxiliar', 'supervisor'];
function canUpload(req: Request): boolean { function canUpload(req: Request): boolean {
return ROLES_UPLOAD.includes(req.user!.role); return ROLES_UPLOAD.includes(req.user!.role);
@@ -81,8 +82,9 @@ const createDeclaracionSchema = z.object({
año: z.number().int().min(2020).max(2100), año: z.number().int().min(2020).max(2100),
mes: z.number().int().min(1).max(12), mes: z.number().int().min(1).max(12),
tipo: z.enum(['normal', 'complementaria']), tipo: z.enum(['normal', 'complementaria']),
periodicidad: z.enum(['mensual', 'bimestral', 'trimestral', 'semestral', 'anual']).optional(), periodicidad: z.enum(['mensual', 'bimestral', 'trimestral', 'cuatrimestral', 'semestral', 'anual']).optional(),
impuestos: z.array(z.enum(['IVA', 'ISR', 'IEPS', 'SUELDOS', 'DIOT', 'OTRO'])).min(1, 'Selecciona al menos un impuesto'), impuestos: z.array(z.enum(['IVA', 'ISR', 'IEPS', 'ISN', 'DIOT', 'OTRO', 'ISH'])).optional(),
obligacionesIds: z.array(z.string().uuid()).optional(),
montoPago: z.number().min(0).optional(), montoPago: z.number().min(0).optional(),
pdfBase64: z.string().min(100), pdfBase64: z.string().min(100),
pdfFilename: z.string().min(1).max(255), pdfFilename: z.string().min(1).max(255),
@@ -92,6 +94,9 @@ const createDeclaracionSchema = z.object({
}).refine( }).refine(
d => !d.ligaPagoBase64 || !!d.ligaPagoFilename, d => !d.ligaPagoBase64 || !!d.ligaPagoFilename,
{ message: 'Si incluyes liga de pago, también debes mandar su nombre de archivo', path: ['ligaPagoFilename'] }, { message: 'Si incluyes liga de pago, también debes mandar su nombre de archivo', path: ['ligaPagoFilename'] },
).refine(
d => (d.obligacionesIds && d.obligacionesIds.length > 0) || (d.impuestos && d.impuestos.length > 0),
{ message: 'Selecciona al menos una obligación fiscal o un impuesto', path: ['obligacionesIds'] },
); );
export async function listarDeclaraciones(req: Request, res: Response, next: NextFunction) { export async function listarDeclaraciones(req: Request, res: Response, next: NextFunction) {
@@ -119,6 +124,7 @@ export async function crearDeclaracion(req: Request, res: Response, next: NextFu
}); });
// Notificación fire-and-forget a owners del despacho + supervisor del RFC. // Notificación fire-and-forget a owners del despacho + supervisor del RFC.
// Incluye como adjuntos el acuse de declaración y la liga de pago (si se subió).
// No bloquea la respuesta ni falla la creación si SMTP no está configurado. // No bloquea la respuesta ni falla la creación si SMTP no está configurado.
notifyDocumentoSubido({ notifyDocumentoSubido({
pool: req.tenantPool!, pool: req.tenantPool!,
@@ -126,6 +132,7 @@ export async function crearDeclaracion(req: Request, res: Response, next: NextFu
contribuyenteId: contribuyenteId ?? null, contribuyenteId: contribuyenteId ?? null,
subidoPor: req.user!.email, subidoPor: req.user!.email,
kind: 'declaracion', kind: 'declaracion',
declaracionId: result.declaracion.id,
declaracion: { declaracion: {
periodo: `${MESES[data.mes - 1]} ${data.año}`, periodo: `${MESES[data.mes - 1]} ${data.año}`,
tipo: data.tipo, tipo: data.tipo,
@@ -229,6 +236,9 @@ export async function consultarConstanciaManual(req: Request, res: Response, nex
res.json(constancia); res.json(constancia);
} catch (error: any) { } catch (error: any) {
if (error.message?.includes('FIEL')) return res.status(400).json({ error: error.message }); if (error.message?.includes('FIEL')) return res.status(400).json({ error: error.message });
if (error.message?.includes('Timeout') || error.name === 'TimeoutError') {
return res.status(504).json({ error: 'El portal del SAT no respondió a tiempo. Intenta de nuevo en unos minutos.' });
}
next(error); next(error);
} }
} }
@@ -331,3 +341,91 @@ export async function listarCategoriasExtras(req: Request, res: Response, next:
res.json(data); res.json(data);
} catch (error) { next(error); } } catch (error) { next(error); }
} }
// ═══════════════════════════════════════════════════════════════════════════
// Obligación evidencias — documentos que cierran obligaciones fiscales
// ═══════════════════════════════════════════════════════════════════════════
const createEvidenciaObligacionSchema = z.object({
contribuyenteId: z.string().uuid('contribuyenteId inválido'),
obligacionId: z.string().uuid('obligacionId inválido'),
periodo: z.string().regex(/^\d{4}-\d{2}$/, 'periodo debe ser YYYY-MM'),
tipoDocumento: z.enum(['declaracion', 'pago', 'acuse', 'complemento']),
pdfBase64: z.string().min(100, 'PDF requerido'),
pdfFilename: z.string().min(1).max(255),
notas: z.string().max(2000).optional(),
});
export async function listarEvidenciasObligacion(req: Request, res: Response, next: NextFunction) {
try {
const contribuyenteId = req.query.contribuyenteId as string | undefined;
if (!contribuyenteId) return next(new AppError(400, 'contribuyenteId requerido'));
const periodo = req.query.periodo as string | undefined;
const obligacionId = req.query.obligacionId as string | undefined;
const data = await obligacionEvidenciasService.listEvidencias(req.tenantPool!, contribuyenteId, {
periodo,
obligacionId,
});
res.json(data);
} catch (error) { next(error); }
}
export async function crearEvidenciaObligacion(req: Request, res: Response, next: NextFunction) {
try {
if (!canUpload(req)) return res.status(403).json({ message: 'No tienes permiso para subir documentos' });
const data = createEvidenciaObligacionSchema.parse(req.body);
const result = await obligacionEvidenciasService.createEvidencia(req.tenantPool!, {
...data,
subidoPor: req.user!.userId,
subidoPorEmail: req.user!.email,
});
// Notificación fire-and-forget a owners + supervisor del contribuyente.
const { rows: obRows } = await req.tenantPool!.query<{ nombre: string }>(
'SELECT nombre FROM obligaciones_contribuyente WHERE id = $1',
[data.obligacionId],
);
notifyDocumentoSubido({
pool: req.tenantPool!,
tenantId: req.viewingTenantId ?? req.user!.tenantId,
contribuyenteId: data.contribuyenteId,
subidoPor: req.user!.email,
kind: 'obligacion_evidencia',
evidencia: {
obligacionNombre: obRows[0]?.nombre || 'Obligación fiscal',
periodo: data.periodo,
tipoDocumento: data.tipoDocumento,
filename: data.pdfFilename,
},
pdfBase64: data.pdfBase64,
}).catch((err: any) => console.error('[notifyDocumentoSubido obligacion_evidencia]', err?.message || err));
res.status(201).json(result);
} catch (error: any) {
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
next(error);
}
}
export async function descargarEvidenciaObligacion(req: Request, res: Response, next: NextFunction) {
try {
const id = parseInt(String(req.params.id));
if (isNaN(id)) return next(new AppError(400, 'id inválido'));
const pdf = await obligacionEvidenciasService.getEvidenciaPdf(req.tenantPool!, id);
if (!pdf) return next(new AppError(404, 'Evidencia no encontrada'));
res.setHeader('Content-Type', pdf.mime);
res.setHeader('Content-Disposition', `attachment; filename="${pdf.filename}"`);
res.send(pdf.buffer);
} catch (error) { next(error); }
}
export async function eliminarEvidenciaObligacion(req: Request, res: Response, next: NextFunction) {
try {
if (!canUpload(req)) return res.status(403).json({ message: 'No tienes permiso para eliminar documentos' });
const id = parseInt(String(req.params.id));
if (isNaN(id)) return next(new AppError(400, 'id inválido'));
const result = await obligacionEvidenciasService.deleteEvidencia(req.tenantPool!, id);
if (!result) return next(new AppError(404, 'Evidencia no encontrada'));
res.status(204).send();
} catch (error) { next(error); }
}

View File

@@ -8,6 +8,9 @@ import {
downloadPdfContribuyente, downloadPdfContribuyente,
downloadXmlContribuyente, downloadXmlContribuyente,
sendInvoiceByEmailContribuyente, sendInvoiceByEmailContribuyente,
getCustomizationContribuyente,
uploadLogoContribuyente,
updateColorContribuyente,
} from '../services/contribuyente-facturapi.service.js'; } from '../services/contribuyente-facturapi.service.js';
import { parseXml } from '../services/sat/sat-parser.service.js'; import { parseXml } from '../services/sat/sat-parser.service.js';
import * as tenantsService from '../services/tenants.service.js'; import * as tenantsService from '../services/tenants.service.js';
@@ -15,6 +18,7 @@ import { prisma } from '../config/database.js';
import { AppError } from '../middlewares/error.middleware.js'; import { AppError } from '../middlewares/error.middleware.js';
import { hasPlatformRole } from '../utils/platform-admin.js'; import { hasPlatformRole } from '../utils/platform-admin.js';
import { auditFromReq } from '../utils/audit.js'; import { auditFromReq } from '../utils/audit.js';
import { recomputarSaldoPendiente } from '../utils/saldo.js';
function effectiveTenantId(req: Request): string { function effectiveTenantId(req: Request): string {
return req.viewingTenantId || req.user!.tenantId; return req.viewingTenantId || req.user!.tenantId;
@@ -134,6 +138,17 @@ export async function emitir(req: Request, res: Response, next: NextFunction) {
} }
} }
// ── Validar fecha de emisión (solo I, E, T) ──
const tipo = req.body.type || 'I';
if (tipo !== 'P' && req.body.fechaEmision) {
const fecha = new Date(req.body.fechaEmision);
const now = new Date();
const minDate = new Date(now.getTime() - 72 * 60 * 60 * 1000);
if (isNaN(fecha.getTime()) || fecha > now || fecha < minDate) {
throw new AppError(400, 'La fecha de emisión debe estar entre 72 horas en el pasado y el momento actual');
}
}
// Reservar timbre — si falla emisión en Facturapi, revertimos abajo // Reservar timbre — si falla emisión en Facturapi, revertimos abajo
const consumedTimbre = await facturapiService.consumeTimbre(tenantId); const consumedTimbre = await facturapiService.consumeTimbre(tenantId);
@@ -272,6 +287,11 @@ export async function emitir(req: Request, res: Response, next: NextFunction) {
contribuyenteId ?? null, xmlString, contribuyenteId ?? null, xmlString,
]); ]);
// Inicializar saldo pendiente para I/PPD (igual que el flujo SAT)
if (parsed.tipoComprobante === 'I' && parsed.metodoPago === 'PPD' && parsed.uuid) {
await recomputarSaldoPendiente(pool, [parsed.uuid]);
}
// Enviar por email si el receptor tiene email — ruteado a la org correcta // Enviar por email si el receptor tiene email — ruteado a la org correcta
const customerEmail = req.body.customer?.email; const customerEmail = req.body.customer?.email;
if (customerEmail) { if (customerEmail) {
@@ -325,7 +345,7 @@ export async function cancelar(req: Request, res: Response, next: NextFunction)
try { try {
const tenantId = effectiveTenantId(req); const tenantId = effectiveTenantId(req);
const { uuid } = req.params; const { uuid } = req.params;
const { motive, substitution } = req.body; const { motive, substitution, contribuyenteId: bodyContribuyenteId } = req.body;
const pool = req.tenantPool!; const pool = req.tenantPool!;
const { rows } = await pool.query( const { rows } = await pool.query(
@@ -340,6 +360,12 @@ export async function cancelar(req: Request, res: Response, next: NextFunction)
const facturapiId = rows[0].facturapi_id; const facturapiId = rows[0].facturapi_id;
const cfdiContribuyenteId = rows[0].contribuyente_id as string | null; const cfdiContribuyenteId = rows[0].contribuyente_id as string | null;
// En modelo multi-contribuyente: si el caller envía un contribuyenteId,
// solo puede cancelar facturas de ESE contribuyente.
if (bodyContribuyenteId && cfdiContribuyenteId && bodyContribuyenteId !== cfdiContribuyenteId) {
return res.status(403).json({ message: 'No tienes permiso para cancelar esta factura' });
}
const result = cfdiContribuyenteId const result = cfdiContribuyenteId
? await cancelInvoiceContribuyente(pool, cfdiContribuyenteId, facturapiId, motive || '02', substitution) ? await cancelInvoiceContribuyente(pool, cfdiContribuyenteId, facturapiId, motive || '02', substitution)
: await facturapiService.cancelInvoice(tenantId, facturapiId, motive || '02', substitution); : await facturapiService.cancelInvoice(tenantId, facturapiId, motive || '02', substitution);
@@ -454,6 +480,38 @@ export async function updateColor(req: Request, res: Response, next: NextFunctio
} catch (error) { next(error); } } catch (error) { next(error); }
} }
// ── Personalización per-contribuyente ──
export async function getCustomizationContribuyenteCtrl(req: Request, res: Response, next: NextFunction) {
try {
const contribuyenteId = String(req.params.id);
const data = await getCustomizationContribuyente(req.tenantPool!, contribuyenteId);
res.json(data || {});
} catch (error) { next(error); }
}
export async function uploadLogoContribuyenteCtrl(req: Request, res: Response, next: NextFunction) {
try {
const contribuyenteId = String(req.params.id);
const { logo } = req.body;
if (!logo) return res.status(400).json({ message: 'Logo es requerido (base64)' });
const result = await uploadLogoContribuyente(req.tenantPool!, contribuyenteId, logo);
if (!result.success) return res.status(400).json({ message: result.message });
res.json(result);
} catch (error) { next(error); }
}
export async function updateColorContribuyenteCtrl(req: Request, res: Response, next: NextFunction) {
try {
const contribuyenteId = String(req.params.id);
const { color } = req.body;
if (!color) return res.status(400).json({ message: 'Color es requerido' });
const result = await updateColorContribuyente(req.tenantPool!, contribuyenteId, color);
if (!result.success) return res.status(400).json({ message: result.message });
res.json(result);
} catch (error) { next(error); }
}
// ── Datos fiscales del tenant ── // ── Datos fiscales del tenant ──
// Schema Zod para preferencias de auto-facturación // Schema Zod para preferencias de auto-facturación
@@ -522,7 +580,13 @@ export async function searchConceptos(req: Request, res: Response, next: NextFun
const params: any[] = []; const params: any[] = [];
if (q.length >= 2) { if (q.length >= 2) {
params.push(`%${q}%`); params.push(`%${q}%`);
whereSearch = `AND (cc.descripcion ILIKE $1 OR cc.clave_prod_serv ILIKE $1)`; whereSearch = `AND (cc.descripcion ILIKE $${params.length} OR cc.clave_prod_serv ILIKE $${params.length})`;
}
let whereContribuyente = '';
if (contribuyenteId) {
params.push(contribuyenteId);
whereContribuyente = `AND c.contribuyente_id = $${params.length}`;
} }
const { rows } = await pool.query(` const { rows } = await pool.query(`
@@ -547,6 +611,7 @@ export async function searchConceptos(req: Request, res: Response, next: NextFun
WHERE c.status NOT IN ('Cancelado', '0') WHERE c.status NOT IN ('Cancelado', '0')
${whereType} ${whereType}
${whereSearch} ${whereSearch}
${whereContribuyente}
ORDER BY cc.clave_prod_serv, cc.descripcion, c.fecha_emision DESC ORDER BY cc.clave_prod_serv, cc.descripcion, c.fecha_emision DESC
LIMIT 30 LIMIT 30
`, params); `, params);
@@ -650,7 +715,7 @@ export async function searchRfcs(req: Request, res: Response, next: NextFunction
const q = (req.query.q as string || '').trim(); const q = (req.query.q as string || '').trim();
if (q.length < 3) return res.json([]); if (q.length < 3) return res.json([]);
const contribuyenteId = (req.query.contribuyenteId as string || '').trim(); const contribuyenteId = (req.query.contribuyenteId as string || '').replace(/[^a-f0-9-]/gi, '');
const pool = req.tenantPool!; const pool = req.tenantPool!;
// RFC del tenant despacho para excluirlo (no se factura a sí mismo) // RFC del tenant despacho para excluirlo (no se factura a sí mismo)
@@ -661,10 +726,17 @@ export async function searchRfcs(req: Request, res: Response, next: NextFunction
}); });
const tenantRfc = tenant?.rfc || ''; const tenantRfc = tenant?.rfc || '';
// Búsqueda en el catálogo completo de RFCs. El contribuyente activo solo const params: any[] = [tenantRfc, `%${q}%`];
// filtra CFDIs relacionados / PPD, no el autocompleto de RFCs — de lo let whereContribuyente = '';
// contrario no se podría facturar a un cliente nuevo que nunca haya if (contribuyenteId) {
// aparecido en un CFDI previo. params.push(contribuyenteId);
whereContribuyente = `AND id IN (
SELECT rfc_receptor_id FROM cfdis WHERE contribuyente_id = $${params.length} AND rfc_receptor_id IS NOT NULL
UNION
SELECT rfc_emisor_id FROM cfdis WHERE contribuyente_id = $${params.length} AND rfc_emisor_id IS NOT NULL
)`;
}
const { rows } = await pool.query(` const { rows } = await pool.query(`
SELECT id, rfc, razon_social as "razonSocial", SELECT id, rfc, razon_social as "razonSocial",
regimen_fiscal as "regimenFiscal", regimen_fiscal as "regimenFiscal",
@@ -672,9 +744,10 @@ export async function searchRfcs(req: Request, res: Response, next: NextFunction
FROM rfcs FROM rfcs
WHERE rfc != $1 WHERE rfc != $1
AND (rfc ILIKE $2 OR razon_social ILIKE $2) AND (rfc ILIKE $2 OR razon_social ILIKE $2)
${whereContribuyente}
ORDER BY razon_social ORDER BY razon_social
LIMIT 10 LIMIT 10
`, [tenantRfc, `%${q}%`]); `, params);
res.json(rows); res.json(rows);
} catch (error) { next(error); } } catch (error) { next(error); }

View File

@@ -3,29 +3,42 @@ import { z } from 'zod';
import { AppError } from '../middlewares/error.middleware.js'; import { AppError } from '../middlewares/error.middleware.js';
import { import {
EMAIL_TYPES, EMAIL_TYPES,
getEmailPreferencesPorContribuyente, NOTIFICATION_ROLES,
setContribuyenteEmailPreferences, getRoleEmailPreferences,
setRoleEmailPreference,
type EmailType,
type NotificationRole,
} from '../services/notification-preferences.service.js'; } from '../services/notification-preferences.service.js';
export async function listPreferences(req: Request, res: Response, next: NextFunction) { export async function listPreferences(req: Request, res: Response, next: NextFunction) {
try { try {
const data = await getEmailPreferencesPorContribuyente(req.tenantPool!); const preferences = await getRoleEmailPreferences(req.tenantPool!);
res.json({ emailTypes: EMAIL_TYPES, data }); res.json({
emailTypes: EMAIL_TYPES,
roles: NOTIFICATION_ROLES,
preferences,
});
} catch (error) { } catch (error) {
next(error); next(error);
} }
} }
const updateSchema = z.object({ const updateSchema = z.object({
contribuyenteId: z.string().uuid(), emailType: z.enum([...EMAIL_TYPES] as [string, ...string[]]),
preferences: z.record(z.string(), z.boolean()), role: z.enum([...NOTIFICATION_ROLES] as [string, ...string[]]),
enabled: z.boolean(),
}); });
export async function updatePreferences(req: Request, res: Response, next: NextFunction) { export async function updatePreferences(req: Request, res: Response, next: NextFunction) {
try { try {
const { contribuyenteId, preferences } = updateSchema.parse(req.body); const { emailType, role, enabled } = updateSchema.parse(req.body);
const updated = await setContribuyenteEmailPreferences(req.tenantPool!, contribuyenteId, preferences); const preferences = await setRoleEmailPreference(
res.json({ contribuyenteId, preferences: updated }); req.tenantPool!,
emailType as EmailType,
role as NotificationRole,
enabled,
);
res.json({ preferences });
} catch (error) { } catch (error) {
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message)); if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
next(error); next(error);

View File

@@ -4,15 +4,10 @@ import { AppError } from '../middlewares/error.middleware.js';
import * as papeleriaService from '../services/papeleria.service.js'; import * as papeleriaService from '../services/papeleria.service.js';
import { emailService } from '../services/email/email.service.js'; import { emailService } from '../services/email/email.service.js';
import { getTenantOwnerEmails, getUserEmailById } from '../utils/memberships.js'; import { getTenantOwnerEmails, getUserEmailById } from '../utils/memberships.js';
import { getEntidadesVisibles } from '../utils/entidades-visibles.js';
import { env } from '../config/env.js'; import { env } from '../config/env.js';
import { prisma } from '../config/database.js'; import { prisma } from '../config/database.js';
function rejectClienteRole(req: Request): void {
if (req.user?.role === 'cliente') {
throw new AppError(403, 'Papelería no disponible para usuarios cliente');
}
}
function effectiveTenantId(req: Request): string { function effectiveTenantId(req: Request): string {
return req.viewingTenantId || req.user!.tenantId; return req.viewingTenantId || req.user!.tenantId;
} }
@@ -24,6 +19,7 @@ const uploadSchema = z.object({
anio: z.number().int().min(2000).max(2100), anio: z.number().int().min(2000).max(2100),
mes: z.number().int().min(1).max(12), mes: z.number().int().min(1).max(12),
requiereAprobacion: z.boolean(), requiereAprobacion: z.boolean(),
requiereAprobacionCliente: z.boolean(),
archivoBase64: z.string().min(1), archivoBase64: z.string().min(1),
archivoFilename: z.string().min(1).max(255), archivoFilename: z.string().min(1).max(255),
archivoMime: z.string().min(1).max(100), archivoMime: z.string().min(1).max(100),
@@ -31,7 +27,9 @@ const uploadSchema = z.object({
export async function upload(req: Request, res: Response, next: NextFunction) { export async function upload(req: Request, res: Response, next: NextFunction) {
try { try {
rejectClienteRole(req); if (req.user?.role === 'cliente') {
throw new AppError(403, 'Los clientes no pueden subir documentos de papelería');
}
const data = uploadSchema.parse(req.body); const data = uploadSchema.parse(req.body);
const archivo = Buffer.from(data.archivoBase64, 'base64'); const archivo = Buffer.from(data.archivoBase64, 'base64');
@@ -42,18 +40,23 @@ export async function upload(req: Request, res: Response, next: NextFunction) {
anio: data.anio, anio: data.anio,
mes: data.mes, mes: data.mes,
requiereAprobacion: data.requiereAprobacion, requiereAprobacion: data.requiereAprobacion,
requiereAprobacionCliente: data.requiereAprobacionCliente,
archivo, archivo,
archivoFilename: data.archivoFilename, archivoFilename: data.archivoFilename,
archivoMime: data.archivoMime, archivoMime: data.archivoMime,
subidoPor: req.user!.userId, subidoPor: req.user!.userId,
}); });
// Notificación a aprobadores si la papelería requiere aprobación.
if (item.requiereAprobacion) { if (item.requiereAprobacion) {
notifyAprobacionRequerida(req, item).catch(err => notifyAprobacionRequerida(req, item).catch(err =>
console.error('[papeleria.upload] notify aprobadores failed:', err?.message || err), console.error('[papeleria.upload] notify aprobadores failed:', err?.message || err),
); );
} }
if (item.requiereAprobacionCliente) {
notifyClienteAprobacionRequerida(req, item).catch(err =>
console.error('[papeleria.upload] notify clientes failed:', err?.message || err),
);
}
res.status(201).json(item); res.status(201).json(item);
} catch (error: any) { } catch (error: any) {
@@ -74,13 +77,20 @@ const listSchema = z.object({
export async function list(req: Request, res: Response, next: NextFunction) { export async function list(req: Request, res: Response, next: NextFunction) {
try { try {
rejectClienteRole(req);
const q = listSchema.parse(req.query); const q = listSchema.parse(req.query);
const entidadIds = await getEntidadesVisibles(
req.tenantPool!, req.user!.userId, req.user!.role,
);
if (!entidadIds.includes(q.contribuyenteId)) {
return res.json([]);
}
const items = await papeleriaService.listPapeleria(req.tenantPool!, { const items = await papeleriaService.listPapeleria(req.tenantPool!, {
contribuyenteId: q.contribuyenteId, contribuyenteId: q.contribuyenteId,
anio: q.anio ? parseInt(q.anio, 10) : undefined, anio: q.anio ? parseInt(q.anio, 10) : undefined,
mes: q.mes ? parseInt(q.mes, 10) : undefined, mes: q.mes ? parseInt(q.mes, 10) : undefined,
estado: q.estado, estado: q.estado,
entidadIds,
userRole: req.user!.role,
}); });
res.json(items); res.json(items);
} catch (error) { } catch (error) {
@@ -91,9 +101,22 @@ export async function list(req: Request, res: Response, next: NextFunction) {
export async function download(req: Request, res: Response, next: NextFunction) { export async function download(req: Request, res: Response, next: NextFunction) {
try { try {
rejectClienteRole(req);
const id = parseInt(String(req.params.id), 10); const id = parseInt(String(req.params.id), 10);
if (isNaN(id)) return next(new AppError(400, 'ID inválido')); if (isNaN(id)) return next(new AppError(400, 'ID inválido'));
const item = await papeleriaService.getById(req.tenantPool!, id);
if (!item) return next(new AppError(404, 'Documento no encontrado'));
const entidadIds = await getEntidadesVisibles(
req.tenantPool!, req.user!.userId, req.user!.role,
);
if (!entidadIds.includes(item.contribuyenteId)) {
return next(new AppError(403, 'No tienes acceso a este documento'));
}
if (req.user!.role === 'cliente' && !item.requiereAprobacionCliente) {
return next(new AppError(403, 'No tienes acceso a este documento'));
}
const file = await papeleriaService.downloadArchivo(req.tenantPool!, id); const file = await papeleriaService.downloadArchivo(req.tenantPool!, id);
if (!file) return next(new AppError(404, 'Documento no encontrado')); if (!file) return next(new AppError(404, 'Documento no encontrado'));
res.setHeader('Content-Type', file.mime); res.setHeader('Content-Type', file.mime);
@@ -106,7 +129,9 @@ export async function download(req: Request, res: Response, next: NextFunction)
export async function aprobar(req: Request, res: Response, next: NextFunction) { export async function aprobar(req: Request, res: Response, next: NextFunction) {
try { try {
rejectClienteRole(req); if (req.user?.role === 'cliente') {
throw new AppError(403, 'Los clientes no pueden usar este endpoint');
}
const id = parseInt(String(req.params.id), 10); const id = parseInt(String(req.params.id), 10);
if (isNaN(id)) return next(new AppError(400, 'ID inválido')); if (isNaN(id)) return next(new AppError(400, 'ID inválido'));
const item = await papeleriaService.aprobar( const item = await papeleriaService.aprobar(
@@ -127,7 +152,9 @@ const rechazarSchema = z.object({ comentario: z.string().max(2000).nullable().op
export async function rechazar(req: Request, res: Response, next: NextFunction) { export async function rechazar(req: Request, res: Response, next: NextFunction) {
try { try {
rejectClienteRole(req); if (req.user?.role === 'cliente') {
throw new AppError(403, 'Los clientes no pueden usar este endpoint');
}
const id = parseInt(String(req.params.id), 10); const id = parseInt(String(req.params.id), 10);
if (isNaN(id)) return next(new AppError(400, 'ID inválido')); if (isNaN(id)) return next(new AppError(400, 'ID inválido'));
const { comentario } = rechazarSchema.parse(req.body); const { comentario } = rechazarSchema.parse(req.body);
@@ -146,9 +173,63 @@ export async function rechazar(req: Request, res: Response, next: NextFunction)
} }
} }
export async function aprobarCliente(req: Request, res: Response, next: NextFunction) {
try {
if (req.user?.role !== 'cliente') {
throw new AppError(403, 'Solo clientes pueden usar este endpoint');
}
const id = parseInt(String(req.params.id), 10);
if (isNaN(id)) return next(new AppError(400, 'ID inválido'));
const entidadIds = await getEntidadesVisibles(
req.tenantPool!, req.user!.userId, req.user!.role,
);
const itemCheck = await papeleriaService.getById(req.tenantPool!, id);
if (!itemCheck || !entidadIds.includes(itemCheck.contribuyenteId) || !itemCheck.requiereAprobacionCliente) {
return next(new AppError(404, 'Documento no encontrado o no requiere tu aprobación'));
}
const item = await papeleriaService.aprobarCliente(req.tenantPool!, id, req.user!.userId);
if (!item) return next(new AppError(404, 'Documento no encontrado o no requiere tu aprobación'));
res.json(item);
} catch (error) {
next(error);
}
}
export async function rechazarCliente(req: Request, res: Response, next: NextFunction) {
try {
if (req.user?.role !== 'cliente') {
throw new AppError(403, 'Solo clientes pueden usar este endpoint');
}
const id = parseInt(String(req.params.id), 10);
if (isNaN(id)) return next(new AppError(400, 'ID inválido'));
const { comentario } = rechazarSchema.parse(req.body);
const entidadIds = await getEntidadesVisibles(
req.tenantPool!, req.user!.userId, req.user!.role,
);
const itemCheck = await papeleriaService.getById(req.tenantPool!, id);
if (!itemCheck || !entidadIds.includes(itemCheck.contribuyenteId) || !itemCheck.requiereAprobacionCliente) {
return next(new AppError(404, 'Documento no encontrado o no requiere tu aprobación'));
}
const item = await papeleriaService.rechazarCliente(
req.tenantPool!, id, req.user!.userId, comentario ?? null,
);
if (!item) return next(new AppError(404, 'Documento no encontrado o no requiere tu aprobación'));
res.json(item);
} catch (error: any) {
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
next(error);
}
}
export async function eliminar(req: Request, res: Response, next: NextFunction) { export async function eliminar(req: Request, res: Response, next: NextFunction) {
try { try {
rejectClienteRole(req); if (req.user?.role === 'cliente') {
throw new AppError(403, 'Los clientes no pueden eliminar documentos');
}
const id = parseInt(String(req.params.id), 10); const id = parseInt(String(req.params.id), 10);
if (isNaN(id)) return next(new AppError(400, 'ID inválido')); if (isNaN(id)) return next(new AppError(400, 'ID inválido'));
const ok = await papeleriaService.eliminar(req.tenantPool!, id); const ok = await papeleriaService.eliminar(req.tenantPool!, id);
@@ -161,22 +242,26 @@ export async function eliminar(req: Request, res: Response, next: NextFunction)
// ─── Notificaciones ─── // ─── Notificaciones ───
async function getContribuyenteInfo(req: Request, contribuyenteId: string) {
const { rows } = await req.tenantPool!.query<{ rfc: string; nombre: string }>(
`SELECT c.rfc, eg.nombre FROM contribuyentes c
JOIN entidades_gestionadas eg ON eg.id = c.entidad_id
WHERE c.entidad_id = $1`,
[contribuyenteId],
);
return rows[0] ?? null;
}
/** /**
* Notifica a owners y supervisores cuando una papelería requiere aprobación. * Notifica a owners y supervisores cuando una papelería requiere aprobación.
* Owners se obtienen de tenant_memberships (BD central). Supervisores se
* resuelven leyendo carteras del tenant.
*/ */
async function notifyAprobacionRequerida( async function notifyAprobacionRequerida(
req: Request, req: Request,
item: papeleriaService.PapeleriaItem, item: papeleriaService.PapeleriaItem,
): Promise<void> { ): Promise<void> {
const tenantId = effectiveTenantId(req); const tenantId = effectiveTenantId(req);
// Owners del despacho
const recipients = new Set<string>(await getTenantOwnerEmails(tenantId)); const recipients = new Set<string>(await getTenantOwnerEmails(tenantId));
// Supervisores: cualquier user con rol 'supervisor' o 'cfo' que pertenezca a este tenant.
// Buscamos vía tenant_memberships + roles.
const supervisores = await prisma.tenantMembership.findMany({ const supervisores = await prisma.tenantMembership.findMany({
where: { tenantId, active: true, rol: { nombre: { in: ['supervisor', 'cfo'] } } }, where: { tenantId, active: true, rol: { nombre: { in: ['supervisor', 'cfo'] } } },
include: { user: { select: { email: true, active: true } } }, include: { user: { select: { email: true, active: true } } },
@@ -185,23 +270,15 @@ async function notifyAprobacionRequerida(
if (m.user.active && m.user.email) recipients.add(m.user.email); if (m.user.active && m.user.email) recipients.add(m.user.email);
} }
// No notificarse a sí mismo
recipients.delete(req.user!.email); recipients.delete(req.user!.email);
if (recipients.size === 0) return; if (recipients.size === 0) return;
const tenant = await prisma.tenant.findUnique({ const tenant = await prisma.tenant.findUnique({
where: { id: tenantId }, where: { id: tenantId },
select: { nombre: true }, select: { nombre: true },
}); });
const info = await getContribuyenteInfo(req, item.contribuyenteId);
const { rows } = await req.tenantPool!.query<{ rfc: string; nombre: string }>( if (!info) return;
`SELECT c.rfc, eg.nombre FROM contribuyentes c
JOIN entidades_gestionadas eg ON eg.id = c.entidad_id
WHERE c.entidad_id = $1`,
[item.contribuyenteId],
);
if (rows.length === 0) return;
const link = `${env.FRONTEND_URL}/documentos`; const link = `${env.FRONTEND_URL}/documentos`;
const meses = ['Ene','Feb','Mar','Abr','May','Jun','Jul','Ago','Sep','Oct','Nov','Dic']; const meses = ['Ene','Feb','Mar','Abr','May','Jun','Jul','Ago','Sep','Oct','Nov','Dic'];
@@ -210,8 +287,8 @@ async function notifyAprobacionRequerida(
for (const to of recipients) { for (const to of recipients) {
try { try {
await emailService.sendPapeleriaAprobacionRequerida(to, { await emailService.sendPapeleriaAprobacionRequerida(to, {
contribuyenteRfc: rows[0].rfc, contribuyenteRfc: info.rfc,
contribuyenteNombre: rows[0].nombre, contribuyenteNombre: info.nombre,
despachoNombre: tenant?.nombre, despachoNombre: tenant?.nombre,
nombreDocumento: item.nombre, nombreDocumento: item.nombre,
descripcion: item.descripcion, descripcion: item.descripcion,
@@ -226,9 +303,7 @@ async function notifyAprobacionRequerida(
} }
/** /**
* Notifica al uploader (auxiliar) cuando un documento que él subió fue * Notifica al uploader cuando un documento fue aprobado o rechazado por owner/supervisor.
* aprobado o rechazado. Solo manda si quien aprobó/rechazó NO es el mismo
* uploader (caso edge: owner sube su propia papelería).
*/ */
async function notifyDecisionAuxiliar( async function notifyDecisionAuxiliar(
req: Request, req: Request,
@@ -238,21 +313,16 @@ async function notifyDecisionAuxiliar(
const auxiliarEmail = await getUserEmailById(item.subidoPor); const auxiliarEmail = await getUserEmailById(item.subidoPor);
if (!auxiliarEmail) return; if (!auxiliarEmail) return;
const { rows } = await req.tenantPool!.query<{ rfc: string; nombre: string }>( const info = await getContribuyenteInfo(req, item.contribuyenteId);
`SELECT c.rfc, eg.nombre FROM contribuyentes c if (!info) return;
JOIN entidades_gestionadas eg ON eg.id = c.entidad_id
WHERE c.entidad_id = $1`,
[item.contribuyenteId],
);
if (rows.length === 0) return;
const link = `${env.FRONTEND_URL}/documentos`; const link = `${env.FRONTEND_URL}/documentos`;
const meses = ['Ene','Feb','Mar','Abr','May','Jun','Jul','Ago','Sep','Oct','Nov','Dic']; const meses = ['Ene','Feb','Mar','Abr','May','Jun','Jul','Ago','Sep','Oct','Nov','Dic'];
const periodo = `${meses[item.mes - 1]} ${item.anio}`; const periodo = `${meses[item.mes - 1]} ${item.anio}`;
await emailService.sendPapeleriaDecision(auxiliarEmail, { await emailService.sendPapeleriaDecision(auxiliarEmail, {
contribuyenteRfc: rows[0].rfc, contribuyenteRfc: info.rfc,
contribuyenteNombre: rows[0].nombre, contribuyenteNombre: info.nombre,
nombreDocumento: item.nombre, nombreDocumento: item.nombre,
estado: item.estado as 'aprobado' | 'rechazado', estado: item.estado as 'aprobado' | 'rechazado',
revisor: req.user!.email, revisor: req.user!.email,
@@ -261,3 +331,57 @@ async function notifyDecisionAuxiliar(
link, link,
}); });
} }
/**
* Notifica a los usuarios cliente asociados al contribuyente cuando un documento
* requiere su aprobación.
*/
async function notifyClienteAprobacionRequerida(
req: Request,
item: papeleriaService.PapeleriaItem,
): Promise<void> {
const tenantId = effectiveTenantId(req);
// Obtener user_ids de clientes con acceso a este contribuyente
const { rows } = await req.tenantPool!.query<{ user_id: string }>(
`SELECT user_id FROM cliente_accesos WHERE entidad_id = $1`,
[item.contribuyenteId],
);
if (rows.length === 0) return;
const userIds = rows.map(r => r.user_id);
const users = await prisma.user.findMany({
where: { id: { in: userIds }, active: true },
select: { email: true },
});
const recipients = users.map(u => u.email).filter(Boolean) as string[];
if (recipients.length === 0) return;
const tenant = await prisma.tenant.findUnique({
where: { id: tenantId },
select: { nombre: true },
});
const info = await getContribuyenteInfo(req, item.contribuyenteId);
if (!info) return;
const link = `${env.FRONTEND_URL}/documentos`;
const meses = ['Ene','Feb','Mar','Abr','May','Jun','Jul','Ago','Sep','Oct','Nov','Dic'];
const periodo = `${meses[item.mes - 1]} ${item.anio}`;
for (const to of recipients) {
try {
await emailService.sendPapeleriaAprobacionClienteRequerida(to, {
contribuyenteRfc: info.rfc,
contribuyenteNombre: info.nombre,
despachoNombre: tenant?.nombre,
nombreDocumento: item.nombre,
descripcion: item.descripcion,
periodo,
subidoPor: req.user!.email,
link,
});
} catch (err: any) {
console.error(`[Email] papeleria-aprobacion-cliente a ${to}:`, err?.message || err);
}
}
}

View File

@@ -184,7 +184,18 @@ export async function subscribeMe(req: Request, res: Response, next: NextFunctio
if (msg.includes('MercadoPago no está configurado')) { if (msg.includes('MercadoPago no está configurado')) {
return res.status(503).json({ message: msg }); return res.status(503).json({ message: msg });
} }
// Otros errores de MP al crear preapproval (monto inválido, email inválido, etc.) // Errores de negocio de MP (monto fuera de límites, payer igual collector, etc.)
if (msg.includes('Cannot pay an amount greater than')) {
return res.status(400).json({
message: 'El monto del plan supera el límite de cobro recurrente de MercadoPago ($10,000 MXN). Usa el pago anual único o contacta a soporte.',
});
}
if (msg.includes('Payer and collector cannot be the same user')) {
return res.status(400).json({
message: 'El correo del pagador no puede ser el mismo que el de la cuenta de MercadoPago del vendedor.',
});
}
// Otros errores de MP al crear preapproval/preference
if (msg.includes('Unauthorized access') || error?.status === 401) { if (msg.includes('Unauthorized access') || error?.status === 401) {
return res.status(503).json({ return res.status(503).json({
message: 'MercadoPago rechazó la solicitud. Verifica que MP_ACCESS_TOKEN sea válido y esté vigente.', message: 'MercadoPago rechazó la solicitud. Verifica que MP_ACCESS_TOKEN sea válido y esté vigente.',

View File

@@ -2,6 +2,7 @@ import type { Request, Response, NextFunction } from 'express';
import { z } from 'zod'; import { z } from 'zod';
import { AppError } from '../middlewares/error.middleware.js'; import { AppError } from '../middlewares/error.middleware.js';
import * as tareasService from '../services/tareas.service.js'; import * as tareasService from '../services/tareas.service.js';
import { getEntidadesVisibles } from '../utils/entidades-visibles.js';
import { emailService } from '../services/email/email.service.js'; import { emailService } from '../services/email/email.service.js';
import { getUserEmailById } from '../utils/memberships.js'; import { getUserEmailById } from '../utils/memberships.js';
import { env } from '../config/env.js'; import { env } from '../config/env.js';
@@ -164,6 +165,17 @@ export async function descompletarPeriodo(req: Request, res: Response, next: Nex
} }
} }
export async function listMisTareas(req: Request, res: Response, next: NextFunction) {
try {
rejectClienteRole(req);
const entidadIds = await getEntidadesVisibles(req.tenantPool!, req.user!.userId, req.user!.role);
const tareas = await tareasService.listTareasConPeriodoPorContribuyentes(req.tenantPool!, entidadIds);
res.json(tareas);
} catch (error) {
next(error);
}
}
export async function seedDefaults(req: Request, res: Response, next: NextFunction) { export async function seedDefaults(req: Request, res: Response, next: NextFunction) {
try { try {
rejectClienteRole(req); rejectClienteRole(req);

View File

@@ -3,6 +3,7 @@ import { z } from 'zod';
import * as tenantsService from '../services/tenants.service.js'; import * as tenantsService from '../services/tenants.service.js';
import { AppError } from '../middlewares/error.middleware.js'; import { AppError } from '../middlewares/error.middleware.js';
import { isGlobalAdmin } from '../utils/global-admin.js'; import { isGlobalAdmin } from '../utils/global-admin.js';
import { hasAnyPlatformRole } from '../utils/platform-admin.js';
import { isOwnerSomewhere } from '../utils/memberships.js'; import { isOwnerSomewhere } from '../utils/memberships.js';
async function requireGlobalAdmin(req: Request): Promise<void> { async function requireGlobalAdmin(req: Request): Promise<void> {
@@ -13,8 +14,10 @@ async function requireGlobalAdmin(req: Request): Promise<void> {
export async function getAllTenants(req: Request, res: Response, next: NextFunction) { export async function getAllTenants(req: Request, res: Response, next: NextFunction) {
try { try {
const isAdmin = await isGlobalAdmin(req.user!.tenantId, req.user!.role, req.user!.userId); // Admin global, TI y Vendedor pueden ver el listado completo de tenants.
if (!isAdmin) { // Vendedor lo necesita para enviar invitaciones de trial.
const canList = await hasAnyPlatformRole(req.user!.userId, 'platform_admin', 'platform_ti', 'platform_sales');
if (!canList) {
// Evita 403 en consola del frontend cuando componentes sin-gate hacen polling // Evita 403 en consola del frontend cuando componentes sin-gate hacen polling
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate'); res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
return res.json([]); return res.json([]);

View File

@@ -1,19 +1,19 @@
import type { Request, Response, NextFunction } from 'express'; import type { Request, Response, NextFunction } from 'express';
import * as trialInvitationService from '../services/trial-invitations.service.js'; import * as trialInvitationService from '../services/trial-invitations.service.js';
import { isGlobalAdmin } from '../utils/global-admin.js'; import { hasAnyPlatformRole } from '../utils/platform-admin.js';
import { prisma } from '../config/database.js'; import { prisma } from '../config/database.js';
async function requireGlobalAdmin(req: Request, res: Response): Promise<boolean> { async function requireAdminOrSales(req: Request, res: Response): Promise<boolean> {
const isAdmin = await isGlobalAdmin(req.user!.tenantId, req.user!.role, req.user!.userId); const isAdmin = await hasAnyPlatformRole(req.user!.userId, 'platform_admin', 'platform_sales');
if (!isAdmin) { if (!isAdmin) {
res.status(403).json({ message: 'Solo el administrador global puede gestionar invitaciones de trial' }); res.status(403).json({ message: 'Solo administradores o vendedores pueden gestionar invitaciones de trial' });
} }
return isAdmin; return isAdmin;
} }
export async function createInvitation(req: Request, res: Response, next: NextFunction) { export async function createInvitation(req: Request, res: Response, next: NextFunction) {
try { try {
if (!(await requireGlobalAdmin(req, res))) return; if (!(await requireAdminOrSales(req, res))) return;
const { tenantId, plan, durationDays } = req.body; const { tenantId, plan, durationDays } = req.body;
if (!tenantId || !durationDays || durationDays < 1 || durationDays > 365) { if (!tenantId || !durationDays || durationDays < 1 || durationDays > 365) {
@@ -38,7 +38,7 @@ export async function createInvitation(req: Request, res: Response, next: NextFu
export async function getAllInvitations(req: Request, res: Response, next: NextFunction) { export async function getAllInvitations(req: Request, res: Response, next: NextFunction) {
try { try {
if (!(await requireGlobalAdmin(req, res))) return; if (!(await requireAdminOrSales(req, res))) return;
const { tenantId, status } = req.query; const { tenantId, status } = req.query;
const invitations = await trialInvitationService.getInvitations({ const invitations = await trialInvitationService.getInvitations({
@@ -85,7 +85,7 @@ export async function acceptInvitation(req: Request, res: Response, next: NextFu
export async function cancelInvitation(req: Request, res: Response, next: NextFunction) { export async function cancelInvitation(req: Request, res: Response, next: NextFunction) {
try { try {
if (!(await requireGlobalAdmin(req, res))) return; if (!(await requireAdminOrSales(req, res))) return;
const id = typeof req.params.id === 'string' ? req.params.id : ''; const id = typeof req.params.id === 'string' ? req.params.id : '';
const result = await trialInvitationService.cancelInvitation(id); const result = await trialInvitationService.cancelInvitation(id);

View File

@@ -3,6 +3,7 @@ import { z } from 'zod';
import * as usuariosService from '../services/usuarios.service.js'; import * as usuariosService from '../services/usuarios.service.js';
import { AppError } from '../middlewares/error.middleware.js'; import { AppError } from '../middlewares/error.middleware.js';
import { isGlobalAdmin as checkGlobalAdmin } from '../utils/global-admin.js'; import { isGlobalAdmin as checkGlobalAdmin } from '../utils/global-admin.js';
import { prisma } from '../config/database.js';
const inviteSchema = z.object({ const inviteSchema = z.object({
email: z.string().email('email inválido'), email: z.string().email('email inválido'),
@@ -26,6 +27,14 @@ const updateGlobalSchema = z.object({
tenantId: z.string().uuid().optional(), tenantId: z.string().uuid().optional(),
}); });
const createGlobalSchema = z.object({
email: z.string().email('email inválido'),
nombre: z.string().min(2).max(100),
role: z.enum(['contador', 'visor', 'auxiliar', 'supervisor', 'cliente']),
tenantId: z.string().uuid('tenantId inválido'),
supervisorUserId: z.string().uuid().optional(),
});
async function isGlobalAdmin(req: Request): Promise<boolean> { async function isGlobalAdmin(req: Request): Promise<boolean> {
return checkGlobalAdmin(req.user!.tenantId, req.user!.role, req.user!.userId); return checkGlobalAdmin(req.user!.tenantId, req.user!.role, req.user!.userId);
} }
@@ -56,11 +65,16 @@ export async function getAllUsuarios(req: Request, res: Response, next: NextFunc
export async function inviteUsuario(req: Request, res: Response, next: NextFunction) { export async function inviteUsuario(req: Request, res: Response, next: NextFunction) {
try { try {
if (req.user!.role !== 'owner') { if (!['owner', 'cfo', 'supervisor'].includes(req.user!.role)) {
throw new AppError(403, 'Solo los dueños pueden invitar usuarios'); throw new AppError(403, 'No autorizado para invitar usuarios');
} }
const data = inviteSchema.parse(req.body); const data = inviteSchema.parse(req.body);
// Los supervisores solo pueden invitar clientes
if (req.user!.role === 'supervisor' && data.role !== 'cliente') {
throw new AppError(403, 'Los supervisores solo pueden invitar clientes');
}
// Validate: auxiliar requires a supervisor // Validate: auxiliar requires a supervisor
if (data.role === 'auxiliar' && !data.supervisorUserId) { if (data.role === 'auxiliar' && !data.supervisorUserId) {
throw new AppError(400, 'Debes asignar un supervisor al auxiliar'); throw new AppError(400, 'Debes asignar un supervisor al auxiliar');
@@ -131,7 +145,16 @@ export async function getSupervisor(req: Request, res: Response, next: NextFunct
LIMIT 1`, LIMIT 1`,
[userId], [userId],
); );
res.json({ supervisorUserId: rows[0]?.supervisor_user_id ?? null }); const supervisorUserId = rows[0]?.supervisor_user_id ?? null;
let supervisorNombre: string | null = null;
if (supervisorUserId) {
const u = await prisma.user.findUnique({
where: { id: supervisorUserId },
select: { nombre: true },
});
supervisorNombre = u?.nombre ?? null;
}
res.json({ supervisorUserId, supervisorNombre });
} catch (error) { } catch (error) {
next(error); next(error);
} }
@@ -190,6 +213,35 @@ export async function deleteUsuario(req: Request, res: Response, next: NextFunct
} }
} }
/**
* Crea un usuario globalmente (solo admin global)
*/
export async function createUsuarioGlobal(req: Request, res: Response, next: NextFunction) {
try {
if (!(await isGlobalAdmin(req))) {
throw new AppError(403, 'Solo el administrador global puede crear usuarios');
}
const data = createGlobalSchema.parse(req.body);
if (data.role === 'auxiliar' && !data.supervisorUserId) {
throw new AppError(400, 'Debes asignar un supervisor al auxiliar');
}
const usuario = await usuariosService.createUsuarioGlobal(data.tenantId, {
email: data.email,
nombre: data.nombre,
role: data.role,
supervisorUserId: data.supervisorUserId,
});
res.status(201).json(usuario);
} catch (error) {
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
next(error);
}
}
/** /**
* Actualiza un usuario globalmente (puede cambiar de empresa) * Actualiza un usuario globalmente (puede cambiar de empresa)
*/ */

View File

@@ -10,6 +10,21 @@ import { despachoPlanTieneDualidadDb } from '../services/plan-catalogo.service.j
import { emailService } from '../services/email/email.service.js'; import { emailService } from '../services/email/email.service.js';
import { getTenantOwnerEmail } from '../utils/memberships.js'; import { getTenantOwnerEmail } from '../utils/memberships.js';
/**
* Calcula la siguiente fecha de fin de período según la frecuencia.
* Usa el mismo algoritmo que Mercado Pago: mismo día del mes siguiente,
* ajustando al último día si el mes destino tiene menos días.
*/
function computeNextPeriodEnd(date: Date, frequency: string): Date {
const d = new Date(date);
if (frequency === 'monthly') {
d.setMonth(d.getMonth() + 1);
} else if (frequency === 'annual' || frequency === 'yearly') {
d.setFullYear(d.getFullYear() + 1);
}
return d;
}
export async function handleMercadoPagoWebhook(req: Request, res: Response, next: NextFunction) { export async function handleMercadoPagoWebhook(req: Request, res: Response, next: NextFunction) {
try { try {
const { type, data } = req.body; const { type, data } = req.body;
@@ -159,6 +174,57 @@ async function handlePaymentNotification(paymentId: string) {
return; return;
} }
// Detecta pagos únicos de suscripción anual (planes >$10k). external_reference = `subscription:${tenantId}:${subscriptionId}`
if (payment.externalReference.startsWith('subscription:')) {
const parts = payment.externalReference.split(':');
const tenantId = parts[1];
const subscriptionId = parts[2];
if (!tenantId || !subscriptionId) {
console.warn('[WEBHOOK] external_reference de subscription malformado:', payment.externalReference);
return;
}
const paymentRecord = await subscriptionService.recordPayment({
tenantId,
subscriptionId,
mpPaymentId: paymentId,
amount: payment.transactionAmount || 0,
status: payment.status || 'unknown',
paymentMethod: payment.paymentMethodId || 'unknown',
});
if (payment.status === 'approved') {
const subscription = await prisma.subscription.findUnique({ where: { id: subscriptionId } });
if (subscription) {
const now = new Date();
const periodEnd = computeNextPeriodEnd(now, 'annual');
await prisma.$transaction([
prisma.subscription.update({
where: { id: subscription.id },
data: {
status: 'authorized',
currentPeriodStart: now,
currentPeriodEnd: periodEnd,
},
}),
prisma.tenant.update({
where: { id: tenantId },
data: { plan: subscription.plan },
}),
]);
subscriptionService.invalidateSubscriptionCache(tenantId);
console.log(`[WEBHOOK] Suscripción ${subscriptionId} activada por pago único anual hasta ${periodEnd.toISOString()}`);
}
// Auto-emisión de factura (fail-soft)
await invoicingService.emitInvoiceIfApplicable(paymentRecord.id);
}
if (typeof process.send === 'function') {
process.send({ type: 'invalidate-tenant-cache', tenantId });
}
return;
}
// Flujo normal: pago recurrente del preapproval // Flujo normal: pago recurrente del preapproval
const tenantId = payment.externalReference; const tenantId = payment.externalReference;
const subscription = await prisma.subscription.findFirst({ const subscription = await prisma.subscription.findFirst({
@@ -187,9 +253,20 @@ async function handlePaymentNotification(paymentId: string) {
// precio de renewal. Se detecta comparando el monto cobrado contra lo que // precio de renewal. Se detecta comparando el monto cobrado contra lo que
// `getPlanPrice(phase='firstYear')` devolvería para este plan. // `getPlanPrice(phase='firstYear')` devolvería para este plan.
const esPrimerPago = subscription.status === 'pending'; const esPrimerPago = subscription.status === 'pending';
const updateData: { status: string; currentPeriodEnd?: Date } = { status: 'authorized' };
// Extender currentPeriodEnd para renovaciones recurrentes.
// El primer pago ya tiene currentPeriodEnd establecido al crear la suscripción;
// solo extendemos en pagos subsecuentes para reflejar el nuevo período cobrado.
if (!esPrimerPago && subscription.currentPeriodEnd) {
const nextPeriodEnd = computeNextPeriodEnd(subscription.currentPeriodEnd, subscription.frequency);
updateData.currentPeriodEnd = nextPeriodEnd;
console.log(`[WEBHOOK] Subscription ${subscription.id} extended to ${nextPeriodEnd.toISOString()} (${subscription.frequency})`);
}
await prisma.subscription.update({ await prisma.subscription.update({
where: { id: subscription.id }, where: { id: subscription.id },
data: { status: 'authorized' }, data: updateData,
}); });
subscriptionService.invalidateSubscriptionCache(tenantId); subscriptionService.invalidateSubscriptionCache(tenantId);

View File

@@ -9,8 +9,10 @@ import { resetExpiredMonthlyTimbres } from '../services/facturapi.service.js';
import { purgeDeclaracionesAntiguas } from '../services/declaraciones.service.js'; import { purgeDeclaracionesAntiguas } from '../services/declaraciones.service.js';
import { consultarConstancia, purgeConstanciasAntiguas } from '../services/constancia.service.js'; import { consultarConstancia, purgeConstanciasAntiguas } from '../services/constancia.service.js';
import { tenantDb } from '../config/database.js'; import { tenantDb } from '../config/database.js';
import type { Pool } from 'pg';
const SYNC_CRON_SCHEDULE = '0 3 * * *'; // 3:00 AM todos los días const SYNC_CRON_SCHEDULE = '0 3 * * *'; // 3:00 AM todos los días
const RECOVERY_CRON_SCHEDULE = '0 10 * * *'; // 10:00 AM todos los días
const CONCURRENT_SYNCS = 3; // Máximo de sincronizaciones simultáneas const CONCURRENT_SYNCS = 3; // Máximo de sincronizaciones simultáneas
const OPINION_CRON_SCHEDULE = '0 4 * * 0'; // Sundays 4:00 AM const OPINION_CRON_SCHEDULE = '0 4 * * 0'; // Sundays 4:00 AM
const CSF_CRON_SCHEDULE = '0 4 1 * *'; // Día 1 de cada mes 04:00 AM (CSF mensual) const CSF_CRON_SCHEDULE = '0 4 1 * *'; // Día 1 de cada mes 04:00 AM (CSF mensual)
@@ -20,6 +22,38 @@ const EXPIRY_REMINDERS_CRON = '0 9 * * *'; // 9:00 AM diario — avisos p
let isRunning = false; let isRunning = false;
let isIncrementalRunning = false; let isIncrementalRunning = false;
let isRecoveryRunning = false;
/**
* Verifica si un tenant tiene FIEL a nivel tenant (legacy Horux 360)
* o a nivel contribuyente (modelo despacho).
*/
async function hasAnyFielConfigured(tenantId: string, databaseName?: string | null): Promise<boolean> {
// 1) FIEL legacy a nivel tenant
const hasLegacy = await hasFielConfigured(tenantId);
if (hasLegacy) return true;
// 2) FIEL por contribuyente (modelo despacho)
if (!databaseName) {
const tenant = await prisma.tenant.findUnique({
where: { id: tenantId },
select: { databaseName: true },
});
databaseName = tenant?.databaseName;
}
if (!databaseName) return false;
try {
const pool = await tenantDb.getPool(tenantId, databaseName);
const { rows } = await pool.query(
`SELECT 1 FROM fiel_contribuyente WHERE is_active = true LIMIT 1`
);
return rows.length > 0;
} catch (err: any) {
console.error(`[SAT Cron] Error verificando FIEL contribuyente para tenant ${tenantId}:`, err.message);
return false;
}
}
/** /**
* Obtiene los tenants que tienen FIEL configurada y activa * Obtiene los tenants que tienen FIEL configurada y activa
@@ -27,13 +61,13 @@ let isIncrementalRunning = false;
async function getTenantsWithFiel(): Promise<string[]> { async function getTenantsWithFiel(): Promise<string[]> {
const tenants = await prisma.tenant.findMany({ const tenants = await prisma.tenant.findMany({
where: { active: true }, where: { active: true },
select: { id: true }, select: { id: true, databaseName: true },
}); });
const tenantsWithFiel: string[] = []; const tenantsWithFiel: string[] = [];
for (const tenant of tenants) { for (const tenant of tenants) {
const hasFiel = await hasFielConfigured(tenant.id); const hasFiel = await hasAnyFielConfigured(tenant.id, tenant.databaseName);
if (hasFiel) { if (hasFiel) {
tenantsWithFiel.push(tenant.id); tenantsWithFiel.push(tenant.id);
} }
@@ -172,12 +206,12 @@ async function getTenantsConSatIncremental(): Promise<string[]> {
const tenants = await prisma.tenant.findMany({ const tenants = await prisma.tenant.findMany({
where: { active: true, plan: { in: planNames as any } }, where: { active: true, plan: { in: planNames as any } },
select: { id: true }, select: { id: true, databaseName: true },
}); });
const result: string[] = []; const result: string[] = [];
for (const tenant of tenants) { for (const tenant of tenants) {
if (await hasFielConfigured(tenant.id)) { if (await hasAnyFielConfigured(tenant.id, tenant.databaseName)) {
result.push(tenant.id); result.push(tenant.id);
} }
} }
@@ -351,12 +385,153 @@ async function runCsfJob(): Promise<void> {
console.error(`[CSF Cron] Error para ${tenant.rfc}:`, error.message); console.error(`[CSF Cron] Error para ${tenant.rfc}:`, error.message);
failed++; failed++;
} }
// Delay entre tenants para no saturar al SAT y reducir bloqueos por IP
await new Promise(r => setTimeout(r, 30_000));
} }
console.log(`[CSF Cron] Completado — éxito: ${success}, fallidos: ${failed}, sin FIEL: ${skipped}`); console.log(`[CSF Cron] Completado — éxito: ${success}, fallidos: ${failed}, sin FIEL: ${skipped}`);
} }
function getYesterdayEnd(): Date {
const now = new Date();
return new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1, 23, 59, 59);
}
async function hasIncompleteCfdis(pool: Pool, contribuyenteId: string): Promise<boolean> {
const { rows } = await pool.query<{ count: string }>(`
SELECT COUNT(*)::text as count
FROM cfdis
WHERE contribuyente_id = $1
AND status = 'Vigente'
AND tipo_comprobante IN ('I', 'E', 'P', 'N')
AND xml_original IS NULL
`, [contribuyenteId]);
return Number(rows[0]?.count || 0) > 0;
}
async function getOldestIncompleteCfdiDate(pool: Pool, contribuyenteId: string): Promise<Date | null> {
const { rows } = await pool.query<{ fecha_emision: Date | null }>(`
SELECT MIN(fecha_emision) as fecha_emision
FROM cfdis
WHERE contribuyente_id = $1
AND status = 'Vigente'
AND tipo_comprobante IN ('I', 'E', 'P', 'N')
AND xml_original IS NULL
`, [contribuyenteId]);
return rows[0]?.fecha_emision || null;
}
async function waitForRecoveryJob(jobId: string): Promise<void> {
while (true) {
const job = await prisma.satSyncJob.findUnique({ where: { id: jobId } });
if (!job || job.status === 'completed' || job.status === 'failed') {
return;
}
await new Promise(resolve => setTimeout(resolve, 60000));
}
}
async function recoverContribuyente(tenantId: string, databaseName: string, contribuyenteId: string): Promise<void> {
try {
const status = await getSyncStatus(tenantId, contribuyenteId);
if (status.hasActiveSync) {
console.log(`[SAT Recovery] ${contribuyenteId} tiene sync activo, omitiendo`);
return;
}
const pool = await tenantDb.getPool(tenantId, databaseName);
const hasIncomplete = await hasIncompleteCfdis(pool, contribuyenteId);
const lastDaily = await prisma.satSyncJob.findFirst({
where: { tenantId, contribuyenteId, type: 'daily' },
orderBy: { startedAt: 'desc' },
});
if (!hasIncomplete && lastDaily?.status !== 'failed') {
return;
}
const dateTo = getYesterdayEnd();
let dateFrom = new Date(dateTo.getFullYear() - 1, dateTo.getMonth(), dateTo.getDate());
if (hasIncomplete) {
const oldest = await getOldestIncompleteCfdiDate(pool, contribuyenteId);
if (oldest) {
dateFrom = new Date(oldest.getFullYear(), oldest.getMonth(), 1);
dateFrom.setMonth(dateFrom.getMonth() - 1);
}
}
console.log(`[SAT Recovery] Recuperando ${contribuyenteId}: ${dateFrom.toISOString()}${dateTo.toISOString()}`);
const jobId = await startSync(tenantId, 'initial', dateFrom, dateTo, contribuyenteId);
console.log(`[SAT Recovery] Job ${jobId} iniciado`);
await waitForRecoveryJob(jobId);
} catch (error: any) {
console.error(`[SAT Recovery] Error recuperando ${contribuyenteId}:`, error.message);
}
}
async function recoverTenant(tenantId: string): Promise<void> {
const tenant = await prisma.tenant.findUnique({
where: { id: tenantId },
select: { databaseName: true },
});
if (!tenant?.databaseName) return;
const pool = await tenantDb.getPool(tenantId, tenant.databaseName);
const { rows } = await pool.query<{ entidad_id: string }>('SELECT entidad_id FROM contribuyentes');
const contribuyenteIds = rows.map(r => r.entidad_id);
if (contribuyenteIds.length === 0) {
const status = await getSyncStatus(tenantId);
if (status.hasActiveSync) return;
const lastDaily = await prisma.satSyncJob.findFirst({
where: { tenantId, contribuyenteId: null, type: 'daily' },
orderBy: { startedAt: 'desc' },
});
if (lastDaily?.status === 'failed') {
const dateTo = getYesterdayEnd();
const dateFrom = new Date(dateTo.getFullYear() - 1, dateTo.getMonth(), dateTo.getDate());
console.log(`[SAT Recovery] Recuperando tenant legacy ${tenantId}`);
const jobId = await startSync(tenantId, 'initial', dateFrom, dateTo);
await waitForRecoveryJob(jobId);
}
return;
}
for (const contribuyenteId of contribuyenteIds) {
await recoverContribuyente(tenantId, tenant.databaseName, contribuyenteId);
}
}
export async function runRecoverySyncJob(): Promise<void> {
if (isRecoveryRunning) {
console.log('[SAT Recovery] Ya en ejecución, omitiendo');
return;
}
isRecoveryRunning = true;
console.log('[SAT Recovery] Iniciando job de recuperación');
try {
const tenantIds = await getTenantsWithFiel();
console.log(`[SAT Recovery] ${tenantIds.length} tenants con FIEL`);
for (const tenantId of tenantIds) {
await recoverTenant(tenantId);
}
console.log('[SAT Recovery] Job de recuperación completado');
} catch (error: any) {
console.error('[SAT Recovery] Error:', error.message);
} finally {
isRecoveryRunning = false;
}
}
let scheduledTask: ReturnType<typeof cron.schedule> | null = null; let scheduledTask: ReturnType<typeof cron.schedule> | null = null;
let retryTask: ReturnType<typeof cron.schedule> | null = null; let retryTask: ReturnType<typeof cron.schedule> | null = null;
let recoveryTask: ReturnType<typeof cron.schedule> | null = null;
let opinionTask: ReturnType<typeof cron.schedule> | null = null; let opinionTask: ReturnType<typeof cron.schedule> | null = null;
let csfTask: ReturnType<typeof cron.schedule> | null = null; let csfTask: ReturnType<typeof cron.schedule> | null = null;
let incrementalTask: ReturnType<typeof cron.schedule> | null = null; let incrementalTask: ReturnType<typeof cron.schedule> | null = null;
@@ -397,6 +572,19 @@ export function startSatSyncJob(): void {
timezone: 'America/Mexico_City', timezone: 'America/Mexico_City',
}); });
// Cron de recuperación: 10:00 AM diario. Revisa si el sync diario falló o si
// hay CFDIs vigentes sin XML, y relanza un sync `initial` con rango extendido
// para completar los XML faltantes.
recoveryTask = cron.schedule(RECOVERY_CRON_SCHEDULE, async () => {
try {
await runRecoverySyncJob();
} catch (error: any) {
console.error('[SAT Recovery Cron] Error:', error.message);
}
}, {
timezone: 'America/Mexico_City',
});
// Cron watchdog: cada 2h marca como `failed` los jobs que quedaron stale // Cron watchdog: cada 2h marca como `failed` los jobs que quedaron stale
// (pending con nextRetryAt > 12h atrás, running con startedAt > 4h atrás). // (pending con nextRetryAt > 12h atrás, running con startedAt > 4h atrás).
// Thresholds sobreescribibles vía env (STALE_PENDING_HOURS / STALE_RUNNING_HOURS) // Thresholds sobreescribibles vía env (STALE_PENDING_HOURS / STALE_RUNNING_HOURS)
@@ -502,6 +690,7 @@ export function startSatSyncJob(): void {
console.log(`[SAT Cron] Job programado para: ${SYNC_CRON_SCHEDULE} (America/Mexico_City)`); console.log(`[SAT Cron] Job programado para: ${SYNC_CRON_SCHEDULE} (America/Mexico_City)`);
console.log(`[SAT Cron] Retry programado cada hora`); console.log(`[SAT Cron] Retry programado cada hora`);
console.log(`[SAT Recovery Cron] Programado para: ${RECOVERY_CRON_SCHEDULE} (America/Mexico_City)`);
console.log(`[Opinion Cron] Programado para: ${OPINION_CRON_SCHEDULE} (America/Mexico_City)`); console.log(`[Opinion Cron] Programado para: ${OPINION_CRON_SCHEDULE} (America/Mexico_City)`);
console.log(`[CSF Cron] Programado para: ${CSF_CRON_SCHEDULE} (America/Mexico_City)`); console.log(`[CSF Cron] Programado para: ${CSF_CRON_SCHEDULE} (America/Mexico_City)`);
console.log(`[SAT Cron Inc] Incremental Enterprise programado para: ${INCREMENTAL_CRON_SCHEDULE} (America/Mexico_City)`); console.log(`[SAT Cron Inc] Incremental Enterprise programado para: ${INCREMENTAL_CRON_SCHEDULE} (America/Mexico_City)`);
@@ -521,6 +710,10 @@ export function stopSatSyncJob(): void {
retryTask.stop(); retryTask.stop();
retryTask = null; retryTask = null;
} }
if (recoveryTask) {
recoveryTask.stop();
recoveryTask = null;
}
if (opinionTask) { if (opinionTask) {
opinionTask.stop(); opinionTask.stop();
opinionTask = null; opinionTask = null;

View File

@@ -13,6 +13,7 @@ import { tenantDb } from '../config/database.js';
import { getKpis } from '../services/dashboard.service.js'; import { getKpis } from '../services/dashboard.service.js';
import { generarAlertasAutomaticas, getDiscrepanciasPorMes } from '../services/alertas-auto.service.js'; import { generarAlertasAutomaticas, getDiscrepanciasPorMes } from '../services/alertas-auto.service.js';
import { emailService } from '../services/email/email.service.js'; import { emailService } from '../services/email/email.service.js';
import { filterRecipientsByRole } from '../services/notification-preferences.service.js';
const SCHEDULE = '0 8 * * 1'; // Lunes 8:00 AM const SCHEDULE = '0 8 * * 1'; // Lunes 8:00 AM
@@ -45,19 +46,27 @@ export async function sendWeeklyUpdateForTenant(tenantId: string): Promise<{ sen
return { sent: 0 }; return { sent: 0 };
} }
// Recipientes: owners activos del tenant // Pool del tenant para queries de preferencias y CFDI
const pool = await tenantDb.getPool(tenantId, tenant.databaseName);
// Recipientes: owners activos del tenant (filtrados por preferencias de rol)
const owners = await prisma.tenantMembership.findMany({ const owners = await prisma.tenantMembership.findMany({
where: { tenantId, isOwner: true, active: true }, where: { tenantId, isOwner: true, active: true },
include: { user: { select: { email: true, nombre: true, active: true } } }, include: { user: { select: { email: true, nombre: true, active: true } } },
}); });
const recipients = owners.filter(o => o.user.active); const activeOwners = owners.filter(o => o.user.active);
if (recipients.length === 0) { if (activeOwners.length === 0) {
console.log(`[Weekly] Tenant ${tenant.rfc} sin owners activos, skip`); console.log(`[Weekly] Tenant ${tenant.rfc} sin owners activos, skip`);
return { sent: 0 }; return { sent: 0 };
} }
// Pool del tenant para queries de CFDI const recipientsWithRole = activeOwners.map(o => ({ email: o.user.email, role: 'owner' as const }));
const pool = await tenantDb.getPool(tenantId, tenant.databaseName); const allowedEmails = new Set(await filterRecipientsByRole(pool, 'weekly_update', recipientsWithRole));
const recipients = activeOwners.filter(o => allowedEmails.has(o.user.email));
if (recipients.length === 0) {
console.log(`[Weekly] Tenant ${tenant.rfc} sin owners con weekly_update habilitado, skip`);
return { sent: 0 };
}
const { fechaInicio, fechaFin, periodoLabel } = currentMonthRange(); const { fechaInicio, fechaFin, periodoLabel } = currentMonthRange();

View File

@@ -118,6 +118,10 @@ CREATE TABLE IF NOT EXISTS cfdis (
facturapi_id VARCHAR(50), facturapi_id VARCHAR(50),
regimen_fiscal_emisor VARCHAR(3), regimen_fiscal_emisor VARCHAR(3),
regimen_fiscal_receptor VARCHAR(3), regimen_fiscal_receptor VARCHAR(3),
periodicidad VARCHAR(2),
meses_global VARCHAR(10),
año_global VARCHAR(4),
fecha_efectiva DATE,
creado_en TIMESTAMP DEFAULT NOW(), creado_en TIMESTAMP DEFAULT NOW(),
actualizado_en TIMESTAMP DEFAULT NOW() actualizado_en TIMESTAMP DEFAULT NOW()
); );

View File

@@ -0,0 +1,11 @@
-- Migration: 007_factura_global
-- Description: Agrega campos de InformacionGlobal y fecha_efectiva para facturas globales
ALTER TABLE cfdis
ADD COLUMN IF NOT EXISTS periodicidad VARCHAR(2),
ADD COLUMN IF NOT EXISTS meses_global VARCHAR(10),
ADD COLUMN IF NOT EXISTS año_global VARCHAR(4),
ADD COLUMN IF NOT EXISTS fecha_efectiva DATE;
-- Crear índice para acelerar métricas que filtran por fecha_efectiva
CREATE INDEX IF NOT EXISTS idx_cfdis_fecha_efectiva ON cfdis(fecha_efectiva);

View File

@@ -0,0 +1,20 @@
CREATE TABLE IF NOT EXISTS obligacion_asignaciones (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
obligacion_id uuid NOT NULL REFERENCES obligaciones_contribuyente(id) ON DELETE CASCADE,
auxiliar_user_id uuid NOT NULL,
asignado_por uuid NOT NULL,
asignado_at timestamptz DEFAULT now(),
UNIQUE (obligacion_id)
);
CREATE TABLE IF NOT EXISTS tarea_asignaciones (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tarea_id uuid NOT NULL REFERENCES tareas_catalogo(id) ON DELETE CASCADE,
auxiliar_user_id uuid NOT NULL,
asignado_por uuid NOT NULL,
asignado_at timestamptz DEFAULT now(),
UNIQUE (tarea_id)
);
CREATE INDEX IF NOT EXISTS idx_obligacion_asignaciones_auxiliar ON obligacion_asignaciones(auxiliar_user_id);
CREATE INDEX IF NOT EXISTS idx_tarea_asignaciones_auxiliar ON tarea_asignaciones(auxiliar_user_id);

View File

@@ -0,0 +1,9 @@
-- Migración 047: Renombrar SUELDOS → ISN en declaraciones existentes
-- Fecha: 2026-05-24
--
-- El campo impuestos es TEXT[]. Se usa array_replace para actualizar
-- declaraciones históricas que tenían 'SUELDOS' como impuesto cubierto.
UPDATE declaraciones_provisionales
SET impuestos = array_replace(impuestos, 'SUELDOS', 'ISN')
WHERE 'SUELDOS' = ANY(impuestos);

View File

@@ -0,0 +1,11 @@
-- Índices para acelerar los filtros de "Considerar activos" en Impuestos.
-- Lookup rápido de facturas tipo I con uso de activo fijo
CREATE INDEX IF NOT EXISTS idx_cfdis_tipo_uso_activos
ON cfdis(tipo_comprobante, uso_cfdi)
WHERE tipo_comprobante = 'I' AND uso_cfdi IN ('I01','I02','I03','I04','I05','I06','I07','I08');
-- Filtrar E's que tienen relacionados (reduce el universo del anti-join)
CREATE INDEX IF NOT EXISTS idx_cfdis_tipo_relacionados
ON cfdis(tipo_comprobante)
WHERE cfdis_relacionados IS NOT NULL;

View File

@@ -0,0 +1,11 @@
-- Índices GIN para acelerar búsquedas de activos en cfdis_relacionados y uuid_relacionado.
-- El filtro "Considerar activos" usa string_to_array(..., '|') para buscar UUIDs
-- relacionados; el índice GIN permite búsquedas @> y ANY eficientes sobre arrays.
CREATE INDEX IF NOT EXISTS idx_cfdis_relacionados_gin
ON cfdis USING gin(string_to_array(LOWER(cfdis_relacionados), '|'))
WHERE cfdis_relacionados IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_cfdis_uuid_relacionado_gin
ON cfdis USING gin(string_to_array(LOWER(uuid_relacionado), '|'))
WHERE uuid_relacionado IS NOT NULL;

View File

@@ -0,0 +1,21 @@
-- Papelería de trabajo: aprobación independiente por cliente
ALTER TABLE papeleria_trabajo
ADD COLUMN IF NOT EXISTS requiere_aprobacion_cliente boolean NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS estado_cliente varchar(20)
CHECK (estado_cliente IS NULL OR estado_cliente IN ('pendiente','aprobado','rechazado')),
ADD COLUMN IF NOT EXISTS aprobado_por_cliente uuid,
ADD COLUMN IF NOT EXISTS aprobado_at_cliente timestamptz,
ADD COLUMN IF NOT EXISTS comentario_rechazo_cliente text;
CREATE INDEX IF NOT EXISTS ix_papeleria_estado_cliente
ON papeleria_trabajo(estado_cliente)
WHERE estado_cliente IS NOT NULL;
CREATE INDEX IF NOT EXISTS ix_papeleria_requiere_cliente
ON papeleria_trabajo(contribuyente_id, requiere_aprobacion_cliente)
WHERE requiere_aprobacion_cliente = true;
INSERT INTO tenant_migrations (scope, version, name)
VALUES ('vertical-contable', 50, '050_papeleria_aprobacion_cliente')
ON CONFLICT (scope, version) DO NOTHING;

View File

@@ -0,0 +1,37 @@
CREATE TABLE IF NOT EXISTS notification_role_preferences (
id SERIAL PRIMARY KEY,
email_type VARCHAR(50) NOT NULL,
role VARCHAR(20) NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
UNIQUE (email_type, role)
);
INSERT INTO notification_role_preferences (email_type, role, enabled)
VALUES
('documento_subido','owner',true),
('documento_subido','supervisor',true),
('documento_subido','auxiliar',true),
('documento_subido','cliente',true),
('weekly_update','owner',true),
('weekly_update','supervisor',true),
('weekly_update','auxiliar',true),
('weekly_update','cliente',true),
('subscription_expiring','owner',true),
('subscription_expiring','supervisor',true),
('subscription_expiring','auxiliar',true),
('subscription_expiring','cliente',true),
('recordatorio_fiscal','owner',true),
('recordatorio_fiscal','supervisor',true),
('recordatorio_fiscal','auxiliar',true),
('recordatorio_fiscal','cliente',true),
('alertas_nuevas','owner',true),
('alertas_nuevas','supervisor',true),
('alertas_nuevas','auxiliar',true),
('alertas_nuevas','cliente',true),
('recordatorio_proximo','owner',true),
('recordatorio_proximo','supervisor',true),
('recordatorio_proximo','auxiliar',true),
('recordatorio_proximo','cliente',true)
ON CONFLICT (email_type, role) DO NOTHING;

View File

@@ -0,0 +1,7 @@
-- Extender periodicidad para soportar declaraciones cuatrimestrales (ej. SISUB)
ALTER TABLE declaraciones_provisionales
DROP CONSTRAINT IF EXISTS declaraciones_provisionales_periodicidad_check;
ALTER TABLE declaraciones_provisionales
ADD CONSTRAINT declaraciones_provisionales_periodicidad_check
CHECK (periodicidad IN ('mensual', 'bimestral', 'trimestral', 'cuatrimestral', 'semestral', 'anual'));

View File

@@ -0,0 +1,25 @@
-- Evidencias de cumplimiento para obligaciones fiscales.
-- Permite subir cualquier documento (declaración, pago, acuse, complemento)
-- vinculado a una obligación y periodo específicos.
CREATE TABLE IF NOT EXISTS obligacion_evidencias (
id serial PRIMARY KEY,
obligacion_id uuid NOT NULL REFERENCES obligaciones_contribuyente(id) ON DELETE CASCADE,
periodo varchar(7) NOT NULL, -- "2026-04"
contribuyente_id uuid NOT NULL REFERENCES contribuyentes(entidad_id) ON DELETE CASCADE,
tipo_documento varchar(30) NOT NULL CHECK (tipo_documento IN (
'declaracion', 'pago', 'acuse', 'complemento'
)),
archivo bytea NOT NULL,
archivo_filename varchar(255) NOT NULL,
archivo_mime varchar(100) DEFAULT 'application/pdf',
notas text,
subido_por uuid, -- UUID del usuario en horux360 (sin FK local)
subido_por_email varchar(255),
created_at timestamptz DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_obligacion_evidencias_obligacion_periodo
ON obligacion_evidencias (obligacion_id, periodo);
CREATE INDEX IF NOT EXISTS idx_obligacion_evidencias_contribuyente
ON obligacion_evidencias (contribuyente_id);

View File

@@ -0,0 +1,16 @@
-- Estados de declaración y pago por separado para obligaciones que requieren ambos.
ALTER TABLE obligacion_periodos
ADD COLUMN IF NOT EXISTS declaracion_presentada boolean DEFAULT false,
ADD COLUMN IF NOT EXISTS pago_presentado boolean DEFAULT false;
-- Backfill: periodos ya completados se consideran con declaración y pago presentados.
UPDATE obligacion_periodos
SET declaracion_presentada = true,
pago_presentado = true
WHERE completada = true
AND (declaracion_presentada IS NULL OR pago_presentado IS NULL);
-- Asegurar que declaracion_presentada y pago_presentado no sean NULL.
ALTER TABLE obligacion_periodos
ALTER COLUMN declaracion_presentada SET NOT NULL,
ALTER COLUMN pago_presentado SET NOT NULL;

View File

@@ -0,0 +1,12 @@
-- Relación entre declaraciones provisionales y obligaciones fiscales.
-- Permite saber exactamente qué obligaciones cierra una declaración
-- y aplicar el comprobante de pago a las mismas obligaciones.
CREATE TABLE IF NOT EXISTS declaracion_obligaciones (
declaracion_id INT NOT NULL REFERENCES declaraciones_provisionales(id) ON DELETE CASCADE,
obligacion_id UUID NOT NULL REFERENCES obligaciones_contribuyente(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (declaracion_id, obligacion_id)
);
CREATE INDEX IF NOT EXISTS idx_declaracion_obligaciones_obligacion
ON declaracion_obligaciones (obligacion_id);

View File

@@ -2,6 +2,7 @@ import { Router, type IRouter } from 'express';
import { authenticate, authorize } from '../middlewares/auth.middleware.js'; import { authenticate, authorize } from '../middlewares/auth.middleware.js';
import { tenantMiddleware } from '../middlewares/tenant.middleware.js'; import { tenantMiddleware } from '../middlewares/tenant.middleware.js';
import * as ctrl from '../controllers/cartera.controller.js'; import * as ctrl from '../controllers/cartera.controller.js';
import * as asignacionesCtrl from '../controllers/asignaciones.controller.js';
const router: IRouter = Router(); const router: IRouter = Router();
@@ -11,6 +12,12 @@ router.use(tenantMiddleware);
// Static routes first // Static routes first
router.get('/supervisores', authorize('owner'), ctrl.getSupervisores); router.get('/supervisores', authorize('owner'), ctrl.getSupervisores);
// Asignaciones de obligaciones/tareas a auxiliares (antes de /:id para evitar match dinámico)
router.get('/asignaciones', authorize('owner', 'supervisor'), asignacionesCtrl.listPorSupervisor);
router.get('/asignaciones/mias', authorize('auxiliar'), asignacionesCtrl.listPorAuxiliar);
router.get('/asignaciones/sin-asignar', authorize('owner', 'supervisor'), asignacionesCtrl.listSinAsignar);
router.get('/asignaciones/auxiliares-elegibles/:contribuyenteId', authorize('owner', 'supervisor'), asignacionesCtrl.listAuxiliaresElegibles);
// Read: owner + supervisor + auxiliar // Read: owner + supervisor + auxiliar
router.get('/', authorize('owner', 'supervisor', 'auxiliar'), ctrl.list); router.get('/', authorize('owner', 'supervisor', 'auxiliar'), ctrl.list);
router.get('/:id', authorize('owner', 'supervisor', 'auxiliar'), ctrl.getById); router.get('/:id', authorize('owner', 'supervisor', 'auxiliar'), ctrl.getById);

View File

@@ -23,6 +23,7 @@ router.get('/conceptos', cfdiController.listConceptos);
router.get('/:id', cfdiController.getCfdiById); router.get('/:id', cfdiController.getCfdiById);
router.get('/:id/conceptos', cfdiController.getConceptos); router.get('/:id/conceptos', cfdiController.getConceptos);
router.get('/:id/xml', cfdiController.getXml); router.get('/:id/xml', cfdiController.getXml);
router.post('/download-xmls', cfdiController.downloadXmlsZip);
router.post('/', checkCfdiLimit, cfdiController.createCfdi); router.post('/', checkCfdiLimit, cfdiController.createCfdi);
// Bulk upload: 10/hora — procesa hasta 50MB, pesado en parseo + inserts // Bulk upload: 10/hora — procesa hasta 50MB, pesado en parseo + inserts
router.post('/bulk', strictLimit, express.json({ limit: '50mb' }), checkCfdiLimit, cfdiController.createManyCfdis); router.post('/bulk', strictLimit, express.json({ limit: '50mb' }), checkCfdiLimit, cfdiController.createManyCfdis);

View File

@@ -3,7 +3,9 @@ import { authenticate, authorize } from '../middlewares/auth.middleware.js';
import { tenantMiddleware } from '../middlewares/tenant.middleware.js'; import { tenantMiddleware } from '../middlewares/tenant.middleware.js';
import * as ctrl from '../controllers/contribuyente.controller.js'; import * as ctrl from '../controllers/contribuyente.controller.js';
import * as configCtrl from '../controllers/contribuyente-config.controller.js'; import * as configCtrl from '../controllers/contribuyente-config.controller.js';
import * as facturacionCtrl from '../controllers/facturacion.controller.js';
import * as obligacionesCtrl from '../controllers/obligaciones.controller.js'; import * as obligacionesCtrl from '../controllers/obligaciones.controller.js';
import * as asignacionesCtrl from '../controllers/asignaciones.controller.js';
const router: IRouter = Router(); const router: IRouter = Router();
@@ -12,7 +14,7 @@ router.use(tenantMiddleware);
// === Static routes FIRST (before /:id to avoid route conflict) === // === Static routes FIRST (before /:id to avoid route conflict) ===
router.get('/', ctrl.list); router.get('/', ctrl.list);
router.post('/', authorize('owner', 'cfo'), ctrl.create); router.post('/', authorize('owner', 'cfo', 'supervisor'), ctrl.create);
router.post('/backfill', authorize('owner'), ctrl.backfill); router.post('/backfill', authorize('owner'), ctrl.backfill);
router.get('/catalogo-obligaciones', obligacionesCtrl.getCatalogo); router.get('/catalogo-obligaciones', obligacionesCtrl.getCatalogo);
@@ -23,25 +25,34 @@ router.delete('/:id', authorize('owner'), ctrl.deactivate);
router.post('/:id/cliente-acceso', authorize('owner', 'supervisor'), ctrl.addClienteAcceso); router.post('/:id/cliente-acceso', authorize('owner', 'supervisor'), ctrl.addClienteAcceso);
// FIEL per contribuyente // FIEL per contribuyente
router.post('/:id/fiel', authorize('owner', 'cfo'), configCtrl.uploadFiel); router.post('/:id/fiel', authorize('owner', 'cfo', 'supervisor'), configCtrl.uploadFiel);
router.get('/:id/fiel/status', configCtrl.fielStatus); router.get('/:id/fiel/status', configCtrl.fielStatus);
router.delete('/:id/fiel', authorize('owner', 'cfo'), configCtrl.deleteFiel); router.delete('/:id/fiel', authorize('owner', 'cfo', 'supervisor'), configCtrl.deleteFiel);
// Facturapi per contribuyente // Facturapi per contribuyente
router.post('/:id/facturapi/org', authorize('owner', 'cfo'), configCtrl.createOrg); router.post('/:id/facturapi/org', authorize('owner', 'cfo'), configCtrl.createOrg);
router.get('/:id/facturapi/status', configCtrl.orgStatus); router.get('/:id/facturapi/status', configCtrl.orgStatus);
router.post('/:id/facturapi/csd', authorize('owner', 'cfo'), configCtrl.uploadCsd); router.post('/:id/facturapi/csd', authorize('owner', 'cfo', 'supervisor'), configCtrl.uploadCsd);
// Personalización per contribuyente
router.get('/:id/facturapi/customization', facturacionCtrl.getCustomizationContribuyenteCtrl);
router.post('/:id/facturapi/logo', authorize('owner', 'cfo'), facturacionCtrl.uploadLogoContribuyenteCtrl);
router.put('/:id/facturapi/color', authorize('owner', 'cfo'), facturacionCtrl.updateColorContribuyenteCtrl);
// Obligaciones fiscales per contribuyente // Obligaciones fiscales per contribuyente
router.get('/:id/obligaciones/periodo', obligacionesCtrl.getObligacionesPorPeriodo); router.get('/:id/obligaciones/periodo', obligacionesCtrl.getObligacionesPorPeriodo);
router.get('/:id/obligaciones', obligacionesCtrl.getObligaciones); router.get('/:id/obligaciones', obligacionesCtrl.getObligaciones);
router.post('/:id/obligaciones/init', authorize('owner', 'cfo'), obligacionesCtrl.initRecomendaciones); router.post('/:id/obligaciones/init', authorize('owner', 'cfo', 'supervisor'), obligacionesCtrl.initRecomendaciones);
router.post('/:id/obligaciones', authorize('owner', 'cfo'), obligacionesCtrl.addObligacion); router.post('/:id/obligaciones', authorize('owner', 'cfo', 'supervisor'), obligacionesCtrl.addObligacion);
router.delete('/:id/obligaciones/:obligacionId', authorize('owner', 'cfo'), obligacionesCtrl.removeObligacion); router.delete('/:id/obligaciones/:obligacionId', authorize('owner', 'cfo', 'supervisor'), obligacionesCtrl.removeObligacion);
router.post('/:id/obligaciones/:obligacionId/restore', authorize('owner', 'cfo'), obligacionesCtrl.restoreObligacion); router.post('/:id/obligaciones/:obligacionId/restore', authorize('owner', 'cfo', 'supervisor'), obligacionesCtrl.restoreObligacion);
router.post('/:id/obligaciones/:obligacionId/complete', authorize('owner', 'cfo', 'contador', 'auxiliar'), obligacionesCtrl.completeObligacion); router.post('/:id/obligaciones/:obligacionId/complete', authorize('owner', 'cfo', 'contador', 'auxiliar'), obligacionesCtrl.completeObligacion);
router.post('/:id/obligaciones/:obligacionId/uncomplete', authorize('owner', 'cfo', 'contador', 'auxiliar'), obligacionesCtrl.uncompleteObligacion); router.post('/:id/obligaciones/:obligacionId/uncomplete', authorize('owner', 'cfo', 'contador', 'auxiliar'), obligacionesCtrl.uncompleteObligacion);
router.post('/:id/obligaciones/:obligacionId/complete-periodo', authorize('owner', 'cfo', 'contador', 'auxiliar'), obligacionesCtrl.completePeriodo); router.post('/:id/obligaciones/:obligacionId/complete-periodo', authorize('owner', 'cfo', 'contador', 'auxiliar'), obligacionesCtrl.completePeriodo);
router.post('/:id/obligaciones/:obligacionId/uncomplete-periodo', authorize('owner', 'cfo', 'contador', 'auxiliar'), obligacionesCtrl.uncompletePeriodo); router.post('/:id/obligaciones/:obligacionId/uncomplete-periodo', authorize('owner', 'cfo', 'contador', 'auxiliar'), obligacionesCtrl.uncompletePeriodo);
// Asignación de obligaciones a auxiliares (supervisor/owner)
router.post('/:id/obligaciones/:obligacionId/asignar', authorize('owner', 'supervisor'), asignacionesCtrl.asignarObligacion);
router.delete('/:id/obligaciones/:obligacionId/asignar', authorize('owner', 'supervisor'), asignacionesCtrl.desasignarObligacion);
export default router; export default router;

View File

@@ -35,4 +35,10 @@ router.post('/extras', documentosController.crearExtra);
router.get('/extras/:id/pdf', documentosController.descargarExtraPdf); router.get('/extras/:id/pdf', documentosController.descargarExtraPdf);
router.delete('/extras/:id', documentosController.eliminarExtra); router.delete('/extras/:id', documentosController.eliminarExtra);
// Evidencias de obligaciones fiscales
router.get('/obligacion-evidencias', documentosController.listarEvidenciasObligacion);
router.post('/obligacion-evidencias', documentosController.crearEvidenciaObligacion);
router.get('/obligacion-evidencias/:id/pdf', documentosController.descargarEvidenciaObligacion);
router.delete('/obligacion-evidencias/:id', documentosController.eliminarEvidenciaObligacion);
export { router as documentosRoutes }; export { router as documentosRoutes };

View File

@@ -13,6 +13,8 @@ router.post('/', ctrl.upload);
router.get('/:id/download', ctrl.download); router.get('/:id/download', ctrl.download);
router.post('/:id/aprobar', ctrl.aprobar); router.post('/:id/aprobar', ctrl.aprobar);
router.post('/:id/rechazar', ctrl.rechazar); router.post('/:id/rechazar', ctrl.rechazar);
router.post('/:id/aprobar-cliente', ctrl.aprobarCliente);
router.post('/:id/rechazar-cliente', ctrl.rechazarCliente);
router.delete('/:id', ctrl.eliminar); router.delete('/:id', ctrl.eliminar);
export { router as papeleriaRoutes }; export { router as papeleriaRoutes };

View File

@@ -1,13 +1,15 @@
import { Router, type IRouter } from 'express'; import { Router, type IRouter } from 'express';
import { authenticate } from '../middlewares/auth.middleware.js'; import { authenticate, authorize } from '../middlewares/auth.middleware.js';
import { tenantMiddleware } from '../middlewares/tenant.middleware.js'; import { tenantMiddleware } from '../middlewares/tenant.middleware.js';
import * as ctrl from '../controllers/tareas.controller.js'; import * as ctrl from '../controllers/tareas.controller.js';
import * as asignacionesCtrl from '../controllers/asignaciones.controller.js';
const router: IRouter = Router(); const router: IRouter = Router();
router.use(authenticate); router.use(authenticate);
router.use(tenantMiddleware); router.use(tenantMiddleware);
router.get('/mis-tareas', ctrl.listMisTareas);
router.get('/', ctrl.listTareas); router.get('/', ctrl.listTareas);
router.post('/', ctrl.createTarea); router.post('/', ctrl.createTarea);
router.post('/seed', ctrl.seedDefaults); router.post('/seed', ctrl.seedDefaults);
@@ -17,4 +19,8 @@ router.delete('/:id', ctrl.deleteTarea);
router.post('/periodo/:id/completar', ctrl.completarPeriodo); router.post('/periodo/:id/completar', ctrl.completarPeriodo);
router.delete('/periodo/:id/completar', ctrl.descompletarPeriodo); router.delete('/periodo/:id/completar', ctrl.descompletarPeriodo);
// Asignación de tareas a auxiliares (supervisor/owner)
router.post('/:id/asignar', authorize('owner', 'supervisor'), asignacionesCtrl.asignarTarea);
router.delete('/:id/asignar', authorize('owner', 'supervisor'), asignacionesCtrl.desasignarTarea);
export { router as tareasRoutes }; export { router as tareasRoutes };

View File

@@ -23,6 +23,7 @@ router.put('/:id/supervisor', tenantMiddleware, usuariosController.updateSupervi
// Rutas globales (solo admin global) // Rutas globales (solo admin global)
router.get('/global/all', usuariosController.getAllUsuarios); router.get('/global/all', usuariosController.getAllUsuarios);
router.post('/global', usuariosController.createUsuarioGlobal);
router.patch('/global/:id', usuariosController.updateUsuarioGlobal); router.patch('/global/:id', usuariosController.updateUsuarioGlobal);
router.delete('/global/:id', usuariosController.deleteUsuarioGlobal); router.delete('/global/:id', usuariosController.deleteUsuarioGlobal);

View File

@@ -0,0 +1,23 @@
import { tenantDb } from '../config/database.js';
import { computeMetricaMensual } from '../services/metricas-compute.service.js';
async function main() {
const tenantId = 'c52c2f5d-b1ae-45c6-8cc8-b11c9611618a';
const dbName = 'horux_hts240708lja';
const contribuyenteId = '4a1d6014-f705-424b-b185-7740be6a80c6';
const pool = await tenantDb.getPool(tenantId, dbName);
for (const mes of [1, 2, 3]) {
console.log(`Recalculando 2026-${String(mes).padStart(2, '0')}...`);
const r = await computeMetricaMensual(pool, tenantId, contribuyenteId, 2026, mes);
console.log(` Filas escritas: ${r.filasEscritas}`);
}
await pool.end();
process.exit(0);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

View File

@@ -24,44 +24,62 @@
* el de activos aplica también pero algunos predicados son no-op funcional * el de activos aplica también pero algunos predicados son no-op funcional
* en subqueries que filtran por tipo_comprobante específico (Postgres los * en subqueries que filtran por tipo_comprobante específico (Postgres los
* optimiza away). * optimiza away).
*
* OPTIMIZACIÓN: los subqueries de exclusiones de activos se reescribieron
* para usar subqueries NO-correlacionados donde sea posible (casos 1-3).
* Esto permite a PostgreSQL ejecutar el subquery una sola vez por query
* principal, en lugar de una vez por cada fila. Solo el caso 4 (anticipo
* referenciado por I07) requiere un correlated EXISTS.
*/ */
const ACTIVOS_USOS = "('I01','I02','I03','I04','I05','I06','I07','I08')"; const ACTIVOS_USOS = "('I01','I02','I03','I04','I05','I06','I07','I08')";
/**
* Subquery no-correlacionado que devuelve todos los UUIDs de facturas tipo I
* con uso de activo. Usado para lookups P→I y E→I.
*/
const UUIDS_ACTIVOS = `SELECT LOWER(uuid) AS uuid FROM cfdis WHERE tipo_comprobante = 'I' AND uso_cfdi IN ${ACTIVOS_USOS}`;
/**
* Subquery no-correlacionado que devuelve todos los UUIDs de E's que
* referencian un activo (directamente I-activo, o indirectamente P→I-activo).
*
* Usa JOIN + UNION en lugar de EXISTS + OR para que PostgreSQL pueda usar
* índices de forma más efectiva (especialmente el GIN en cfdis_relacionados).
*/
const UUIDS_E_DE_ACTIVOS = `
SELECT e.uuid
FROM cfdis e
JOIN cfdis r_act ON LOWER(r_act.uuid) = ANY(string_to_array(LOWER(e.cfdis_relacionados), '|'))
WHERE e.tipo_comprobante = 'E'
AND e.cfdis_relacionados IS NOT NULL
AND r_act.tipo_comprobante = 'I'
AND r_act.uso_cfdi IN ${ACTIVOS_USOS}
UNION ALL
SELECT e.uuid
FROM cfdis e
JOIN cfdis r_act ON LOWER(r_act.uuid) = ANY(string_to_array(LOWER(e.cfdis_relacionados), '|'))
JOIN cfdis pi_act ON LOWER(pi_act.uuid) = LOWER(r_act.uuid_relacionado)
WHERE e.tipo_comprobante = 'E'
AND e.cfdis_relacionados IS NOT NULL
AND r_act.tipo_comprobante = 'P'
AND pi_act.tipo_comprobante = 'I'
AND pi_act.uso_cfdi IN ${ACTIVOS_USOS}
`;
/** /**
* Predicado SQL que detecta si el row actual (sin alias de tabla, asume * Predicado SQL que detecta si el row actual (sin alias de tabla, asume
* `FROM cfdis`) referencia un activo directamente (I), indirectamente vía * `FROM cfdis`) referencia un activo directamente (I), indirectamente vía
* pago (P→I), o transitivamente vía relación (E→I, E→P→I). * pago (P→I), o transitivamente vía relación (E→I, E→P→I).
*
* IMPORTANTE — qualifying outer refs: dentro de los subqueries `cfdis i_act`
* y `cfdis r_act`, la tabla interna también tiene columnas `uuid_relacionado`
* y `cfdis_relacionados`. Una referencia no-qualificada las resolvería a las
* columnas internas (NO al row outer), volviendo el predicado a no-op.
* Por eso usamos `cfdis.uuid_relacionado` y `cfdis.cfdis_relacionados`
* explícitamente — fuerza la resolución al outer.
*/ */
function activosExclusionNoAlias(): string { function activosExclusionNoAlias(): string {
return ` return `
AND NOT (tipo_comprobante = 'I' AND uso_cfdi IN ${ACTIVOS_USOS}) AND NOT (tipo_comprobante = 'I' AND uso_cfdi IN ${ACTIVOS_USOS})
AND NOT (tipo_comprobante = 'P' AND EXISTS ( AND NOT (tipo_comprobante = 'P' AND EXISTS (
SELECT 1 FROM cfdis i_act SELECT 1 FROM (${UUIDS_ACTIVOS}) ua
WHERE LOWER(i_act.uuid) = LOWER(cfdis.uuid_relacionado) WHERE ua.uuid = ANY(string_to_array(LOWER(uuid_relacionado), '|'))
AND i_act.tipo_comprobante = 'I'
AND i_act.uso_cfdi IN ${ACTIVOS_USOS}
))
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_USOS})
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_USOS}
))
)
)) ))
AND NOT (tipo_comprobante = 'E' AND uuid IN (${UUIDS_E_DE_ACTIVOS}))
AND NOT (tipo_comprobante = 'I' AND EXISTS ( AND NOT (tipo_comprobante = 'I' AND EXISTS (
-- Anticipo: CFDI tipo I (puede no tener uso_cfdi de activo) que es -- Anticipo: CFDI tipo I (puede no tener uso_cfdi de activo) que es
-- referenciado por una I/07 PPD con uso_cfdi de activo. La I/07 PPD -- referenciado por una I/07 PPD con uso_cfdi de activo. La I/07 PPD
@@ -87,24 +105,10 @@ function activosExclusionAlias(alias: string): string {
return ` return `
AND NOT (${alias}.tipo_comprobante = 'I' AND ${alias}.uso_cfdi IN ${ACTIVOS_USOS}) AND NOT (${alias}.tipo_comprobante = 'I' AND ${alias}.uso_cfdi IN ${ACTIVOS_USOS})
AND NOT (${alias}.tipo_comprobante = 'P' AND EXISTS ( AND NOT (${alias}.tipo_comprobante = 'P' AND EXISTS (
SELECT 1 FROM cfdis i_act SELECT 1 FROM (${UUIDS_ACTIVOS}) ua
WHERE LOWER(i_act.uuid) = LOWER(${alias}.uuid_relacionado) WHERE ua.uuid = ANY(string_to_array(LOWER(${alias}.uuid_relacionado), '|'))
AND i_act.tipo_comprobante = 'I'
AND i_act.uso_cfdi IN ${ACTIVOS_USOS}
))
AND NOT (${alias}.tipo_comprobante = 'E' AND ${alias}.cfdis_relacionados IS NOT NULL AND EXISTS (
SELECT 1 FROM cfdis r_act
WHERE LOWER(r_act.uuid) = ANY(string_to_array(LOWER(${alias}.cfdis_relacionados), '|'))
AND (
(r_act.tipo_comprobante = 'I' AND r_act.uso_cfdi IN ${ACTIVOS_USOS})
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_USOS}
))
)
)) ))
AND NOT (${alias}.tipo_comprobante = 'E' AND ${alias}.uuid IN (${UUIDS_E_DE_ACTIVOS}))
AND NOT (${alias}.tipo_comprobante = 'I' AND EXISTS ( AND NOT (${alias}.tipo_comprobante = 'I' AND EXISTS (
SELECT 1 FROM cfdis i07_act SELECT 1 FROM cfdis i07_act
WHERE i07_act.tipo_comprobante = 'I' WHERE i07_act.tipo_comprobante = 'I'

View File

@@ -66,11 +66,13 @@ export async function getClientesStats(range: ClientesStatsRange): Promise<Clien
paymentsCount: payments._count, paymentsCount: payments._count,
}; };
// 3) Clientes que NO renovaron: subs cuyo currentPeriodEnd cae en el rango // 3) Clientes que NO renovaron:
// y que están en status terminal (cancelled, trial_expired, paused) o sin // a) Subs cuyo currentPeriodEnd cae en el rango y están en status terminal
// payment posterior aprobado. Nota: un sub `authorized` con periodEnd // (cancelled, trial_expired, paused).
// pasado es un "se renovó automáticamente" — para detectar no-renovaciones // b) Tenants cuyo trialEndsAt ya pasó y NO tienen suscripción authorized
// miramos status efectivo + ausencia de payment en los siguientes 7 días. // (incluye trials que nunca convirtieron o cuya sub fue borrada).
// c) Tenants con sub trial vencida (currentPeriodEnd < ahora) que nunca
// fue marcada trial_expired por el cron.
const subsExpiradas = await prisma.subscription.findMany({ const subsExpiradas = await prisma.subscription.findMany({
where: { where: {
currentPeriodEnd: { gte: range.from, lte: range.to }, currentPeriodEnd: { gte: range.from, lte: range.to },
@@ -84,14 +86,99 @@ export async function getClientesStats(range: ClientesStatsRange): Promise<Clien
tenant: { select: { id: true, nombre: true, rfc: true } }, tenant: { select: { id: true, nombre: true, rfc: true } },
}, },
}); });
const noRenovaciones = subsExpiradas.map(s => ({
tenantId: s.tenantId, const noRenovacionesMap = new Map<string, ClientesStats['noRenovaciones'][number]>();
tenantNombre: s.tenant?.nombre ?? '', for (const s of subsExpiradas) {
rfc: s.tenant?.rfc ?? '', noRenovacionesMap.set(s.tenantId, {
plan: String(s.plan), tenantId: s.tenantId,
currentPeriodEnd: s.currentPeriodEnd?.toISOString() ?? '', tenantNombre: s.tenant?.nombre ?? '',
statusActual: s.status, rfc: s.tenant?.rfc ?? '',
})); plan: String(s.plan),
currentPeriodEnd: s.currentPeriodEnd?.toISOString() ?? '',
statusActual: s.status,
});
}
// b + c) Trials vencidos / sin suscripción activa / subs borradas
const now = new Date();
const tenantsConSubAutorizada = new Set(
(await prisma.subscription.findMany({
where: { status: 'authorized' },
select: { tenantId: true },
})).map(s => s.tenantId)
);
const excluded = Array.from(tenantsConSubAutorizada);
// Tenants con trialEndsAt pasado y sin sub authorized
const tenantsTrialsVencidos = await prisma.tenant.findMany({
where: {
trialEndsAt: { lt: now },
id: { notIn: excluded },
},
select: { id: true, nombre: true, rfc: true, plan: true, trialEndsAt: true },
});
for (const t of tenantsTrialsVencidos) {
if (noRenovacionesMap.has(t.id)) continue;
noRenovacionesMap.set(t.id, {
tenantId: t.id,
tenantNombre: t.nombre,
rfc: t.rfc ?? '',
plan: String(t.plan ?? 'trial'),
currentPeriodEnd: t.trialEndsAt?.toISOString() ?? '',
statusActual: 'trial_expired',
});
}
// Tenants con sub trial vencida (currentPeriodEnd < ahora) que nunca fue
// marcada trial_expired por el cron, y no tienen otra sub authorized.
const subsTrialVencidas = await prisma.subscription.findMany({
where: {
status: 'trial',
currentPeriodEnd: { lt: now },
tenantId: { notIn: excluded },
},
select: {
tenantId: true,
plan: true,
currentPeriodEnd: true,
tenant: { select: { id: true, nombre: true, rfc: true } },
},
});
for (const s of subsTrialVencidas) {
if (noRenovacionesMap.has(s.tenantId)) continue;
noRenovacionesMap.set(s.tenantId, {
tenantId: s.tenantId,
tenantNombre: s.tenant?.nombre ?? '',
rfc: s.tenant?.rfc ?? '',
plan: String(s.plan),
currentPeriodEnd: s.currentPeriodEnd?.toISOString() ?? '',
statusActual: 'trial_expired',
});
}
// Tenants con plan de pago asignado manualmente (plan != 'trial') pero
// sin NINGUNA suscripción. Indica que nunca iniciaron el flujo de pago.
const tenantsConPlanPeroSinSub = await prisma.tenant.findMany({
where: {
plan: { not: 'trial' },
id: { notIn: excluded },
subscriptions: { none: {} },
},
select: { id: true, nombre: true, rfc: true, plan: true, createdAt: true },
});
for (const t of tenantsConPlanPeroSinSub) {
if (noRenovacionesMap.has(t.id)) continue;
noRenovacionesMap.set(t.id, {
tenantId: t.id,
tenantNombre: t.nombre,
rfc: t.rfc ?? '',
plan: String(t.plan),
currentPeriodEnd: t.createdAt.toISOString(),
statusActual: 'sin_suscripcion',
});
}
const noRenovaciones = Array.from(noRenovacionesMap.values());
// 4) Usuarios por cliente (memberships activos por tenant) // 4) Usuarios por cliente (memberships activos por tenant)
const memberships = await prisma.tenantMembership.findMany({ const memberships = await prisma.tenantMembership.findMany({

View File

@@ -176,7 +176,7 @@ async function alertaRiesgoCancelaciones(pool: Pool, contribuyenteId?: string |
COUNT(*)::int as total, COUNT(*)::int as total,
COUNT(CASE WHEN status IN ('Cancelado', '0') THEN 1 END)::int as cancelados COUNT(CASE WHEN status IN ('Cancelado', '0') THEN 1 END)::int as cancelados
FROM cfdis FROM cfdis
WHERE fecha_emision >= $1::date WHERE (fecha_emision - interval '1 hour') >= $1::date
${cf} ${cf}
`, [fechaDesde]); `, [fechaDesde]);
@@ -359,7 +359,7 @@ async function alertaCancelacionPeriodoAnterior(pool: Pool, contribuyenteId?: st
FROM cfdis FROM cfdis
WHERE status IN ('Cancelado', '0') WHERE status IN ('Cancelado', '0')
AND fecha_cancelacion >= $1::date AND fecha_cancelacion >= $1::date
AND fecha_emision < $1::date AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') < $1::date
${cf} ${cf}
`, [inicioMes]); `, [inicioMes]);
@@ -529,7 +529,7 @@ async function alertaResicoPfLimiteIngresos(
FROM cfdis FROM cfdis
WHERE type = 'EMITIDO' WHERE type = 'EMITIDO'
AND status NOT IN ('Cancelado', '0') AND status NOT IN ('Cancelado', '0')
AND EXTRACT(YEAR FROM fecha_emision) = $1 AND EXTRACT(YEAR FROM COALESCE(fecha_efectiva, fecha_emision - interval '1 hour')) = $1
AND contribuyente_id = $2 AND contribuyente_id = $2
`, [año, safeId]); `, [año, safeId]);
@@ -609,30 +609,46 @@ async function alertaOpinionCumplimiento(pool: Pool, contribuyenteId?: string |
/** /**
* Genera todas las alertas automáticas para un tenant. * Genera todas las alertas automáticas para un tenant.
* Cada alerta se envuelve en try/catch para que un fallo en una no
* bloquee el resto (robustez ante timeouts o errores transitorios).
*/ */
export async function generarAlertasAutomaticas( export async function generarAlertasAutomaticas(
pool: Pool, pool: Pool,
tenantId: string, tenantId: string,
contribuyenteId?: string | null, contribuyenteId?: string | null,
): Promise<AlertaAuto[]> { ): Promise<AlertaAuto[]> {
const alertas = await Promise.all([ const generadores: { name: string; fn: () => Promise<AlertaAuto | null> }[] = [
alertaListaNegraPropia(pool, tenantId, contribuyenteId), { name: 'lista-negra-propia', fn: () => alertaListaNegraPropia(pool, tenantId, contribuyenteId) },
alertaClienteListaNegra(pool, contribuyenteId), { name: 'lista-negra-clientes', fn: () => alertaClienteListaNegra(pool, contribuyenteId) },
alertaProveedorListaNegra(pool, contribuyenteId), { name: 'lista-negra-proveedores', fn: () => alertaProveedorListaNegra(pool, contribuyenteId) },
alertaDiscrepanciaRegimen(pool, tenantId, contribuyenteId), { name: 'discrepancia-regimen', fn: () => alertaDiscrepanciaRegimen(pool, tenantId, contribuyenteId) },
alertaConcentracionClientes(pool, contribuyenteId), { name: 'concentracion-clientes', fn: () => alertaConcentracionClientes(pool, contribuyenteId) },
alertaConcentracionProveedores(pool, contribuyenteId), { name: 'concentracion-proveedores', fn: () => alertaConcentracionProveedores(pool, contribuyenteId) },
alertaRiesgoCambiario(pool, contribuyenteId), { name: 'riesgo-cambiario', fn: () => alertaRiesgoCambiario(pool, contribuyenteId) },
alertaRiesgoCancelaciones(pool, contribuyenteId), { name: 'riesgo-cancelaciones', fn: () => alertaRiesgoCancelaciones(pool, contribuyenteId) },
alertaRiesgoTransaccional(pool, contribuyenteId), { name: 'riesgo-transaccional', fn: () => alertaRiesgoTransaccional(pool, contribuyenteId) },
alertaCancelacionPeriodoAnterior(pool, contribuyenteId), { name: 'cancelacion-periodo-anterior', fn: () => alertaCancelacionPeriodoAnterior(pool, contribuyenteId) },
alertaOpinionCumplimiento(pool, contribuyenteId), { name: 'opinion-cumplimiento', fn: () => alertaOpinionCumplimiento(pool, contribuyenteId) },
alertaTipoRelacionSospechosa(pool, contribuyenteId), { name: 'tipo-relacion-sospechosa', fn: () => alertaTipoRelacionSospechosa(pool, contribuyenteId) },
alertaTareasProximasVencer(pool, contribuyenteId), { name: 'tareas-proximas-vencer', fn: () => alertaTareasProximasVencer(pool, contribuyenteId) },
alertaResicoPfLimiteIngresos(pool, contribuyenteId), { name: 'resico-pf-limite-ingresos', fn: () => alertaResicoPfLimiteIngresos(pool, contribuyenteId) },
]); ];
return alertas.filter((a): a is AlertaAuto => a !== null); const alertas: AlertaAuto[] = [];
for (const g of generadores) {
try {
const a = await g.fn();
if (a) alertas.push(a);
} catch (err: any) {
console.error(`[AlertasAuto] Fallo ${g.name} (tenant=${tenantId}, contribuyente=${contribuyenteId}):`, err.message || err);
}
}
if (alertas.length > 0) {
console.log(`[AlertasAuto] tenant=${tenantId} contribuyente=${contribuyenteId || 'null'} generadas=${alertas.map(a => a.id).join(', ')}`);
}
return alertas;
} }
/** /**
@@ -659,8 +675,8 @@ export async function getDiscrepanciasPorMes(
const { rows } = await pool.query(` const { rows } = await pool.query(`
SELECT SELECT
EXTRACT(YEAR FROM fecha_emision)::int as año, EXTRACT(YEAR FROM COALESCE(fecha_efectiva, fecha_emision - interval '1 hour'))::int as año,
EXTRACT(MONTH FROM fecha_emision)::int as mes, EXTRACT(MONTH FROM COALESCE(fecha_efectiva, fecha_emision - interval '1 hour'))::int as mes,
COUNT(*)::int as count COUNT(*)::int as count
FROM cfdis FROM cfdis
WHERE type = 'RECIBIDO' AND ${VIGENTE} WHERE type = 'RECIBIDO' AND ${VIGENTE}

View File

@@ -119,6 +119,7 @@ function appliesToPeriod(frecuencia: string | null, periodo: string): boolean {
case 'mensual': return true; case 'mensual': return true;
case 'bimestral': return month % 2 === 1; case 'bimestral': return month % 2 === 1;
case 'trimestral': return [1, 4, 7, 10].includes(month); case 'trimestral': return [1, 4, 7, 10].includes(month);
case 'cuatrimestral': return [1, 5, 9].includes(month);
case 'anual': return month === 3 || month === 4; case 'anual': return month === 3 || month === 4;
case 'eventual': return false; case 'eventual': return false;
default: return true; default: return true;

View File

@@ -0,0 +1,343 @@
import type { Pool } from 'pg';
import { prisma } from '../config/database.js';
// ── Asignación de obligaciones ──
export async function asignarObligacion(
pool: Pool,
obligacionId: string,
auxiliarUserId: string,
asignadoPor: string,
): Promise<void> {
await pool.query(
`INSERT INTO obligacion_asignaciones (obligacion_id, auxiliar_user_id, asignado_por)
VALUES ($1, $2, $3)
ON CONFLICT (obligacion_id)
DO UPDATE SET auxiliar_user_id = $2, asignado_por = $3, asignado_at = now()`,
[obligacionId, auxiliarUserId, asignadoPor],
);
}
export async function desasignarObligacion(pool: Pool, obligacionId: string): Promise<void> {
await pool.query('DELETE FROM obligacion_asignaciones WHERE obligacion_id = $1', [obligacionId]);
}
// ── Asignación de tareas ──
export async function asignarTarea(
pool: Pool,
tareaId: string,
auxiliarUserId: string,
asignadoPor: string,
): Promise<void> {
await pool.query(
`INSERT INTO tarea_asignaciones (tarea_id, auxiliar_user_id, asignado_por)
VALUES ($1, $2, $3)
ON CONFLICT (tarea_id)
DO UPDATE SET auxiliar_user_id = $2, asignado_por = $3, asignado_at = now()`,
[tareaId, auxiliarUserId, asignadoPor],
);
}
export async function desasignarTarea(pool: Pool, tareaId: string): Promise<void> {
await pool.query('DELETE FROM tarea_asignaciones WHERE tarea_id = $1', [tareaId]);
}
// ── Listados ──
export interface AsignacionObligacion {
id: string;
obligacionId: string;
obligacionNombre: string;
contribuyenteId: string;
contribuyenteRfc: string;
contribuyenteRazonSocial: string;
auxiliarUserId: string;
auxiliarNombre: string | null;
asignadoPor: string;
asignadoAt: string;
}
export interface AsignacionTarea {
id: string;
tareaId: string;
tareaNombre: string;
contribuyenteId: string;
contribuyenteRfc: string;
contribuyenteRazonSocial: string;
auxiliarUserId: string;
auxiliarNombre: string | null;
asignadoPor: string;
asignadoAt: string;
}
async function resolveUserNames(userIds: string[]): Promise<Map<string, string>> {
const map = new Map<string, string>();
if (userIds.length === 0) return map;
const users = await prisma.user.findMany({
where: { id: { in: userIds } },
select: { id: true, nombre: true },
});
for (const u of users) {
map.set(u.id, u.nombre);
}
return map;
}
/**
* Devuelve todas las asignaciones de obligaciones y tareas de los auxiliares
* que pertenecen al supervisor indicado (vía auxiliar_supervisores).
* Owner ve todas las asignaciones del tenant.
*/
export async function getAsignacionesPorSupervisor(
pool: Pool,
supervisorUserId: string,
role: string,
): Promise<{ obligaciones: AsignacionObligacion[]; tareas: AsignacionTarea[] }> {
const isOwner = role === 'owner' || role === 'cfo' || role === 'contador';
// Relación supervisor → auxiliar se infiere desde carteras (directas y
// subcarteras) con fallback a la tabla legacy auxiliar_supervisores.
const supervisorFilter = isOwner
? ''
: `AND EXISTS (
SELECT 1 FROM (
SELECT c.auxiliar_user_id
FROM carteras c
WHERE c.supervisor_user_id = $1
AND c.auxiliar_user_id IS NOT NULL
UNION
SELECT sub.auxiliar_user_id
FROM carteras sub
JOIN carteras p ON p.id = sub.parent_id
WHERE p.supervisor_user_id = $1
AND sub.auxiliar_user_id IS NOT NULL
UNION
SELECT auxiliar_user_id FROM auxiliar_supervisores WHERE supervisor_user_id = $1
) sup_aux WHERE sup_aux.auxiliar_user_id = __AUX_COL__
)`;
const whereObl = isOwner
? 'WHERE 1=1'
: `WHERE 1=1 ${supervisorFilter.replace(/__AUX_COL__/g, 'oa.auxiliar_user_id')}`;
const whereTarea = isOwner
? 'WHERE 1=1'
: `WHERE 1=1 ${supervisorFilter.replace(/__AUX_COL__/g, 'ta.auxiliar_user_id')}`;
const params = isOwner ? [] : [supervisorUserId];
const { rows: obligaciones } = await pool.query<AsignacionObligacion>(
`SELECT
oa.id,
oa.obligacion_id AS "obligacionId",
oc.nombre AS "obligacionNombre",
oc.contribuyente_id AS "contribuyenteId",
c.rfc AS "contribuyenteRfc",
COALESCE(r.razon_social, c.rfc) AS "contribuyenteRazonSocial",
oa.auxiliar_user_id AS "auxiliarUserId",
oa.asignado_por AS "asignadoPor",
oa.asignado_at AS "asignadoAt"
FROM obligacion_asignaciones oa
JOIN obligaciones_contribuyente oc ON oc.id = oa.obligacion_id
JOIN contribuyentes c ON c.entidad_id = oc.contribuyente_id
LEFT JOIN rfcs r ON UPPER(r.rfc) = UPPER(c.rfc)
${whereObl}
ORDER BY oa.asignado_at DESC`,
params,
);
const { rows: tareas } = await pool.query<AsignacionTarea>(
`SELECT
ta.id,
ta.tarea_id AS "tareaId",
tc.nombre AS "tareaNombre",
tc.contribuyente_id AS "contribuyenteId",
c.rfc AS "contribuyenteRfc",
COALESCE(r.razon_social, c.rfc) AS "contribuyenteRazonSocial",
ta.auxiliar_user_id AS "auxiliarUserId",
ta.asignado_por AS "asignadoPor",
ta.asignado_at AS "asignadoAt"
FROM tarea_asignaciones ta
JOIN tareas_catalogo tc ON tc.id = ta.tarea_id
JOIN contribuyentes c ON c.entidad_id = tc.contribuyente_id
LEFT JOIN rfcs r ON UPPER(r.rfc) = UPPER(c.rfc)
${whereTarea}
ORDER BY ta.asignado_at DESC`,
params,
);
const allAuxIds = [...new Set([
...obligaciones.map(o => o.auxiliarUserId),
...tareas.map(t => t.auxiliarUserId),
])];
const names = await resolveUserNames(allAuxIds);
return {
obligaciones: obligaciones.map(o => ({ ...o, auxiliarNombre: names.get(o.auxiliarUserId) ?? null })),
tareas: tareas.map(t => ({ ...t, auxiliarNombre: names.get(t.auxiliarUserId) ?? null })),
};
}
/**
* Devuelve las asignaciones del auxiliar logueado.
*/
export async function getAsignacionesPorAuxiliar(
pool: Pool,
auxiliarUserId: string,
): Promise<{ obligaciones: AsignacionObligacion[]; tareas: AsignacionTarea[] }> {
const { rows: obligaciones } = await pool.query<AsignacionObligacion>(
`SELECT
oa.id,
oa.obligacion_id AS "obligacionId",
oc.nombre AS "obligacionNombre",
oc.contribuyente_id AS "contribuyenteId",
c.rfc AS "contribuyenteRfc",
COALESCE(r.razon_social, c.rfc) AS "contribuyenteRazonSocial",
oa.auxiliar_user_id AS "auxiliarUserId",
oa.asignado_por AS "asignadoPor",
oa.asignado_at AS "asignadoAt"
FROM obligacion_asignaciones oa
JOIN obligaciones_contribuyente oc ON oc.id = oa.obligacion_id
JOIN contribuyentes c ON c.entidad_id = oc.contribuyente_id
LEFT JOIN rfcs r ON UPPER(r.rfc) = UPPER(c.rfc)
WHERE oa.auxiliar_user_id = $1
ORDER BY oa.asignado_at DESC`,
[auxiliarUserId],
);
const { rows: tareas } = await pool.query<AsignacionTarea>(
`SELECT
ta.id,
ta.tarea_id AS "tareaId",
tc.nombre AS "tareaNombre",
tc.contribuyente_id AS "contribuyenteId",
c.rfc AS "contribuyenteRfc",
COALESCE(r.razon_social, c.rfc) AS "contribuyenteRazonSocial",
ta.auxiliar_user_id AS "auxiliarUserId",
ta.asignado_por AS "asignadoPor",
ta.asignado_at AS "asignadoAt"
FROM tarea_asignaciones ta
JOIN tareas_catalogo tc ON tc.id = ta.tarea_id
JOIN contribuyentes c ON c.entidad_id = tc.contribuyente_id
LEFT JOIN rfcs r ON UPPER(r.rfc) = UPPER(c.rfc)
WHERE ta.auxiliar_user_id = $1
ORDER BY ta.asignado_at DESC`,
[auxiliarUserId],
);
const names = await resolveUserNames([auxiliarUserId]);
const auxName = names.get(auxiliarUserId) ?? null;
return {
obligaciones: obligaciones.map(o => ({ ...o, auxiliarNombre: auxName })),
tareas: tareas.map(t => ({ ...t, auxiliarNombre: auxName })),
};
}
/**
* Devuelve obligaciones activas sin asignar para los contribuyentes indicados.
*/
export async function getObligacionesSinAsignar(
pool: Pool,
entidadIds: string[],
): Promise<Omit<AsignacionObligacion, 'id' | 'auxiliarUserId' | 'auxiliarNombre' | 'asignadoPor' | 'asignadoAt'>[]> {
if (entidadIds.length === 0) return [];
const { rows } = await pool.query(
`SELECT
oc.id AS "obligacionId",
oc.nombre AS "obligacionNombre",
oc.contribuyente_id AS "contribuyenteId",
c.rfc AS "contribuyenteRfc",
COALESCE(r.razon_social, c.rfc) AS "contribuyenteRazonSocial"
FROM obligaciones_contribuyente oc
JOIN contribuyentes c ON c.entidad_id = oc.contribuyente_id
LEFT JOIN rfcs r ON UPPER(r.rfc) = UPPER(c.rfc)
LEFT JOIN obligacion_asignaciones oa ON oa.obligacion_id = oc.id
WHERE oc.activa = true AND oa.id IS NULL AND oc.contribuyente_id = ANY($1)
ORDER BY c.rfc, oc.nombre`,
[entidadIds],
);
return rows;
}
/**
* Devuelve tareas activas sin asignar para los contribuyentes indicados.
*/
export async function getTareasSinAsignar(
pool: Pool,
entidadIds: string[],
): Promise<Omit<AsignacionTarea, 'id' | 'auxiliarUserId' | 'auxiliarNombre' | 'asignadoPor' | 'asignadoAt'>[]> {
if (entidadIds.length === 0) return [];
const { rows } = await pool.query(
`SELECT
tc.id AS "tareaId",
tc.nombre AS "tareaNombre",
tc.contribuyente_id AS "contribuyenteId",
c.rfc AS "contribuyenteRfc",
COALESCE(r.razon_social, c.rfc) AS "contribuyenteRazonSocial"
FROM tareas_catalogo tc
JOIN contribuyentes c ON c.entidad_id = tc.contribuyente_id
LEFT JOIN rfcs r ON UPPER(r.rfc) = UPPER(c.rfc)
LEFT JOIN tarea_asignaciones ta ON ta.tarea_id = tc.id
WHERE tc.active = true AND ta.id IS NULL AND tc.contribuyente_id = ANY($1)
ORDER BY c.rfc, tc.nombre`,
[entidadIds],
);
return rows;
}
/**
* Resuelve el auxiliar asignado a una obligación (o null).
*/
export async function getAuxiliarAsignadoObligacion(
pool: Pool,
obligacionId: string,
): Promise<{ auxiliarUserId: string; auxiliarNombre: string | null } | null> {
const { rows } = await pool.query<{ auxiliar_user_id: string }>(
`SELECT oa.auxiliar_user_id
FROM obligacion_asignaciones oa
WHERE oa.obligacion_id = $1`,
[obligacionId],
);
if (rows.length === 0) return null;
const auxId = rows[0].auxiliar_user_id;
const names = await resolveUserNames([auxId]);
return { auxiliarUserId: auxId, auxiliarNombre: names.get(auxId) ?? null };
}
/**
* Resuelve el auxiliar asignado a una tarea (o null).
*/
export async function getAuxiliarAsignadoTarea(
pool: Pool,
tareaId: string,
): Promise<{ auxiliarUserId: string; auxiliarNombre: string | null } | null> {
const { rows } = await pool.query<{ auxiliar_user_id: string }>(
`SELECT ta.auxiliar_user_id
FROM tarea_asignaciones ta
WHERE ta.tarea_id = $1`,
[tareaId],
);
if (rows.length === 0) return null;
const auxId = rows[0].auxiliar_user_id;
const names = await resolveUserNames([auxId]);
return { auxiliarUserId: auxId, auxiliarNombre: names.get(auxId) ?? null };
}
/**
* Devuelve los userIds de auxiliares que tienen al contribuyente en alguna
* de sus subcarteras (carteras con auxiliar_user_id no nulo que contienen
* al contribuyente en cartera_entidades).
*/
export async function getAuxiliaresElegibles(
pool: Pool,
contribuyenteId: string,
): Promise<string[]> {
const { rows } = await pool.query<{ auxiliar_user_id: string }>(
`SELECT DISTINCT c.auxiliar_user_id
FROM carteras c
JOIN cartera_entidades ce ON ce.cartera_id = c.id
WHERE ce.entidad_id = $1
AND c.auxiliar_user_id IS NOT NULL`,
[contribuyenteId],
);
return rows.map(r => r.auxiliar_user_id);
}

View File

@@ -590,18 +590,6 @@ export async function switchTenant(params: {
throw new AppError(404, 'Empresa no encontrada o desactivada'); throw new AppError(404, 'Empresa no encontrada o desactivada');
} }
// Persiste el target como "último tenant activo" — al re-loguear caerá aquí
// sin tener que volver a hacer switch.
const previousTenantId = user.lastTenantId;
await prisma.user.update({
where: { id: user.id },
data: { lastTenantId: targetTenant.id },
});
// Invalida el refresh token actual (puede no existir si el caller pasó el
// access token por error — deleteMany es idempotente).
await prisma.refreshToken.deleteMany({ where: { token: params.currentRefreshToken } });
const [platformRoles, tenants] = await Promise.all([ const [platformRoles, tenants] = await Promise.all([
getPlatformRoles(user.id), getPlatformRoles(user.id),
getUserTenants(user.id), getUserTenants(user.id),
@@ -619,13 +607,26 @@ export async function switchTenant(params: {
const accessToken = generateAccessToken(tokenPayload); const accessToken = generateAccessToken(tokenPayload);
const refreshToken = generateRefreshToken(tokenPayload); const refreshToken = generateRefreshToken(tokenPayload);
await prisma.refreshToken.create({ // Persiste el target como "último tenant activo" y atomiza la rotacion del
data: { // refresh token (delete + create) para evitar race conditions con requests
userId: user.id, // concurrentes que intenten refrescar con el token anterior.
token: refreshToken, const previousTenantId = user.lastTenantId;
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), await prisma.$transaction([
}, prisma.user.update({
}); where: { id: user.id },
data: { lastTenantId: targetTenant.id },
}),
// Invalida el refresh token actual (puede no existir si el caller pasó el
// access token por error — deleteMany es idempotente).
prisma.refreshToken.deleteMany({ where: { token: params.currentRefreshToken } }),
prisma.refreshToken.create({
data: {
userId: user.id,
token: refreshToken,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
},
}),
]);
auditLog({ auditLog({
userId: user.id, userId: user.id,

View File

@@ -214,6 +214,7 @@ export async function generarEventosDesdeObligaciones(
if (freq === 'mensual') monthsToGenerate.push(m); if (freq === 'mensual') monthsToGenerate.push(m);
else if (freq === 'bimestral' && m % 2 === 1) monthsToGenerate.push(m); else if (freq === 'bimestral' && m % 2 === 1) monthsToGenerate.push(m);
else if (freq === 'trimestral' && [1, 4, 7, 10].includes(m)) monthsToGenerate.push(m); else if (freq === 'trimestral' && [1, 4, 7, 10].includes(m)) monthsToGenerate.push(m);
else if (freq === 'cuatrimestral' && [1, 5, 9].includes(m)) monthsToGenerate.push(m);
else if (freq === 'anual' && (m === 3 || m === 4)) monthsToGenerate.push(m); else if (freq === 'anual' && (m === 3 || m === 4)) monthsToGenerate.push(m);
// 'eventual' and unknown: skip auto-generation // 'eventual' and unknown: skip auto-generation
} }

View File

@@ -110,6 +110,17 @@ export async function deleteCartera(pool: Pool, id: string): Promise<boolean> {
// Entidades in cartera // Entidades in cartera
export async function addEntidadToCartera(pool: Pool, carteraId: string, entidadId: string): Promise<void> { export async function addEntidadToCartera(pool: Pool, carteraId: string, entidadId: string): Promise<void> {
// Si es subcartera, validar que la entidad pertenezca a la cartera padre
const cartera = await getCarteraById(pool, carteraId);
if (cartera?.parentId) {
const { rows } = await pool.query(
'SELECT 1 FROM cartera_entidades WHERE cartera_id = $1 AND entidad_id = $2',
[cartera.parentId, entidadId],
);
if (rows.length === 0) {
throw new Error('La entidad no pertenece a la cartera padre de esta subcartera');
}
}
await pool.query('INSERT INTO cartera_entidades (cartera_id, entidad_id) VALUES ($1, $2) ON CONFLICT DO NOTHING', [carteraId, entidadId]); await pool.query('INSERT INTO cartera_entidades (cartera_id, entidad_id) VALUES ($1, $2) ON CONFLICT DO NOTHING', [carteraId, entidadId]);
} }

View File

@@ -102,12 +102,12 @@ export async function getCfdis(pool: Pool, filters: CfdiFilters): Promise<CfdiLi
} }
if (filters.fechaInicio) { if (filters.fechaInicio) {
whereClause += ` AND fecha_emision >= $${paramIndex++}::date`; whereClause += ` AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') >= $${paramIndex++}::date`;
params.push(filters.fechaInicio); params.push(filters.fechaInicio);
} }
if (filters.fechaFin) { if (filters.fechaFin) {
whereClause += ` AND fecha_emision <= ($${paramIndex++}::date + interval '1 day')`; whereClause += ` AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') <= ($${paramIndex++}::date + interval '1 day')`;
params.push(filters.fechaFin); params.push(filters.fechaFin);
} }
@@ -214,11 +214,11 @@ export async function getConceptosList(
params.push(filters.estado); params.push(filters.estado);
} }
if (filters.fechaInicio) { if (filters.fechaInicio) {
whereClause += ` AND c.fecha_emision >= $${paramIndex++}::date`; whereClause += ` AND COALESCE(c.fecha_efectiva, c.fecha_emision - interval '1 hour') >= $${paramIndex++}::date`;
params.push(filters.fechaInicio); params.push(filters.fechaInicio);
} }
if (filters.fechaFin) { if (filters.fechaFin) {
whereClause += ` AND c.fecha_emision <= ($${paramIndex++}::date + interval '1 day')`; whereClause += ` AND COALESCE(c.fecha_efectiva, c.fecha_emision - interval '1 hour') <= ($${paramIndex++}::date + interval '1 day')`;
params.push(filters.fechaFin); params.push(filters.fechaFin);
} }
if (filters.rfc) { if (filters.rfc) {
@@ -357,6 +357,81 @@ export async function getXmlById(pool: Pool, id: string): Promise<string | null>
return rows[0]?.xml_original || null; return rows[0]?.xml_original || null;
} }
export async function getXmlsByIds(pool: Pool, ids: number[]): Promise<{ id: number; uuid: string; xml: string | null }[]> {
const { rows } = await pool.query(`
SELECT id, uuid, xml_original FROM cfdis WHERE id = ANY($1)
`, [ids]);
return rows.map((r: any) => ({ id: r.id, uuid: r.uuid, xml: r.xml_original || null }));
}
export async function getCfdiXmlsForZip(
pool: Pool,
filters: CfdiFilters
): Promise<{ uuid: string; xml: string | null }[]> {
let whereClause = 'WHERE xml_original IS NOT NULL';
const params: any[] = [];
let paramIndex = 1;
if (filters.tipo && !filters.contribuyenteId) {
whereClause += ` AND type = $${paramIndex++}`;
params.push(filters.tipo);
}
if (filters.tipoComprobante) {
whereClause += ` AND tipo_comprobante = $${paramIndex++}`;
params.push(filters.tipoComprobante);
}
if (filters.estado) {
whereClause += ` AND status = $${paramIndex++}`;
params.push(filters.estado);
}
if (filters.fechaInicio) {
whereClause += ` AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') >= $${paramIndex++}::date`;
params.push(filters.fechaInicio);
}
if (filters.fechaFin) {
whereClause += ` AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') <= ($${paramIndex++}::date + interval '1 day')`;
params.push(filters.fechaFin);
}
if (filters.rfc) {
whereClause += ` AND (rfc_emisor ILIKE $${paramIndex} OR rfc_receptor ILIKE $${paramIndex++})`;
params.push(`%${filters.rfc}%`);
}
if (filters.emisor) {
whereClause += ` AND (rfc_emisor ILIKE $${paramIndex} OR nombre_emisor ILIKE $${paramIndex++})`;
params.push(`%${filters.emisor}%`);
}
if (filters.receptor) {
whereClause += ` AND (rfc_receptor ILIKE $${paramIndex} OR nombre_receptor ILIKE $${paramIndex++})`;
params.push(`%${filters.receptor}%`);
}
if (filters.search) {
whereClause += ` AND (uuid ILIKE $${paramIndex} OR nombre_emisor ILIKE $${paramIndex} OR nombre_receptor ILIKE $${paramIndex} OR rfc_emisor ILIKE $${paramIndex} OR rfc_receptor ILIKE $${paramIndex++})`;
params.push(`%${filters.search}%`);
}
if (filters.contribuyenteId) {
if (filters.tipo === 'EMITIDO') {
whereClause += ` AND rfc_emisor = (SELECT rfc FROM contribuyentes WHERE entidad_id = $${paramIndex++})`;
params.push(filters.contribuyenteId);
} else if (filters.tipo === 'RECIBIDO') {
whereClause += ` AND rfc_receptor = (SELECT rfc FROM contribuyentes WHERE entidad_id = $${paramIndex++})`;
params.push(filters.contribuyenteId);
} else {
whereClause += ` AND (contribuyente_id = $${paramIndex} OR rfc_emisor = (SELECT rfc FROM contribuyentes WHERE entidad_id = $${paramIndex}) OR rfc_receptor = (SELECT rfc FROM contribuyentes WHERE entidad_id = $${paramIndex++}))`;
params.push(filters.contribuyenteId);
}
}
params.push(1000);
const { rows } = await pool.query(`
SELECT uuid, xml_original FROM cfdis
${whereClause}
ORDER BY fecha_emision DESC
LIMIT $${paramIndex++}
`, params);
return rows.map((r: any) => ({ uuid: r.uuid, xml: r.xml_original || null }));
}
export interface CreateCfdiData { export interface CreateCfdiData {
uuid: string; uuid: string;
type: 'EMITIDO' | 'RECIBIDO'; type: 'EMITIDO' | 'RECIBIDO';
@@ -746,7 +821,10 @@ export async function getReceptores(pool: Pool, search: string, limit: number =
} }
export async function getResumenCfdis(pool: Pool, año: number, mes: number, contribuyenteId?: string) { export async function getResumenCfdis(pool: Pool, año: number, mes: number, contribuyenteId?: string) {
let whereClause = `WHERE status NOT IN ('Cancelado', '0') AND year = $1 AND month = $2`; const fi = `${año}-${String(mes).padStart(2, '0')}-01`;
const lastDay = new Date(año, mes, 0).getDate();
const ff = `${año}-${String(mes).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`;
let whereClause = `WHERE status NOT IN ('Cancelado', '0') AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') >= $1::date AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') <= $2::date`;
if (contribuyenteId) { if (contribuyenteId) {
const safeId = contribuyenteId.replace(/[^a-f0-9-]/gi, ''); const safeId = contribuyenteId.replace(/[^a-f0-9-]/gi, '');
whereClause += ` AND (contribuyente_id = '${safeId}' OR rfc_emisor = (SELECT rfc FROM contribuyentes WHERE entidad_id = '${safeId}') OR rfc_receptor = (SELECT rfc FROM contribuyentes WHERE entidad_id = '${safeId}'))`; whereClause += ` AND (contribuyente_id = '${safeId}' OR rfc_emisor = (SELECT rfc FROM contribuyentes WHERE entidad_id = '${safeId}') OR rfc_receptor = (SELECT rfc FROM contribuyentes WHERE entidad_id = '${safeId}'))`;
@@ -761,7 +839,7 @@ export async function getResumenCfdis(pool: Pool, año: number, mes: number, con
COALESCE(SUM(CASE WHEN type = 'RECIBIDO' THEN iva_traslado_mxn ELSE 0 END), 0) as iva_acreditable COALESCE(SUM(CASE WHEN type = 'RECIBIDO' THEN iva_traslado_mxn ELSE 0 END), 0) as iva_acreditable
FROM cfdis FROM cfdis
${whereClause} ${whereClause}
`, [String(año), String(mes).padStart(2, '0')]); `, [fi, ff]);
const r = rows[0]; const r = rows[0];
return { return {

View File

@@ -13,6 +13,8 @@ export interface ConciliacionCfdi {
nombreEmisor: string; nombreEmisor: string;
rfcReceptor: string; rfcReceptor: string;
nombreReceptor: string; nombreReceptor: string;
regimenFiscalEmisor: string | null;
regimenFiscalReceptor: string | null;
total: number; total: number;
totalMxn: number; totalMxn: number;
subtotal: number; subtotal: number;
@@ -68,11 +70,11 @@ export async function getCfdisConConciliacion(
} }
if (filters.fechaInicio) { if (filters.fechaInicio) {
where += ` AND COALESCE(c.fecha_pago_p, c.fecha_emision) >= $${idx++}::date`; where += ` AND CASE WHEN c.tipo_comprobante = 'P' THEN c.fecha_pago_p - interval '1 hour' ELSE COALESCE(c.fecha_efectiva, c.fecha_emision - interval '1 hour') END >= $${idx++}::date`;
params.push(filters.fechaInicio); params.push(filters.fechaInicio);
} }
if (filters.fechaFin) { if (filters.fechaFin) {
where += ` AND COALESCE(c.fecha_pago_p, c.fecha_emision) <= ($${idx++}::date + interval '1 day')`; where += ` AND CASE WHEN c.tipo_comprobante = 'P' THEN c.fecha_pago_p - interval '1 hour' ELSE COALESCE(c.fecha_efectiva, c.fecha_emision - interval '1 hour') END <= ($${idx++}::date + interval '1 day')`;
params.push(filters.fechaFin); params.push(filters.fechaFin);
} }
if (filters.regimen) { if (filters.regimen) {
@@ -98,6 +100,7 @@ export async function getCfdisConConciliacion(
c.fecha_emision as "fechaEmision", c.fecha_emision as "fechaEmision",
c.rfc_emisor as "rfcEmisor", c.nombre_emisor as "nombreEmisor", c.rfc_emisor as "rfcEmisor", c.nombre_emisor as "nombreEmisor",
c.rfc_receptor as "rfcReceptor", c.nombre_receptor as "nombreReceptor", c.rfc_receptor as "rfcReceptor", c.nombre_receptor as "nombreReceptor",
c.regimen_fiscal_emisor as "regimenFiscalEmisor", c.regimen_fiscal_receptor as "regimenFiscalReceptor",
c.total, c.total_mxn as "totalMxn", c.total, c.total_mxn as "totalMxn",
c.subtotal, c.descuento, c.subtotal, c.descuento,
c.moneda, c.tipo_cambio as "tipoCambio", c.moneda, c.tipo_cambio as "tipoCambio",
@@ -136,6 +139,8 @@ export async function getCfdisConConciliacion(
nombreEmisor: r.nombreEmisor, nombreEmisor: r.nombreEmisor,
rfcReceptor: r.rfcReceptor, rfcReceptor: r.rfcReceptor,
nombreReceptor: r.nombreReceptor, nombreReceptor: r.nombreReceptor,
regimenFiscalEmisor: r.regimenFiscalEmisor,
regimenFiscalReceptor: r.regimenFiscalReceptor,
total: Number(r.total), total: Number(r.total),
totalMxn: Number(r.totalMxn), totalMxn: Number(r.totalMxn),
subtotal: Number(r.subtotal || 0), subtotal: Number(r.subtotal || 0),

View File

@@ -44,6 +44,9 @@ function rowToConstancia(r: any): ConstanciaRow {
* sincroniza automáticamente domicilio + regímenes activos con lo que reporta * sincroniza automáticamente domicilio + regímenes activos con lo que reporta
* el SAT. El auto-fill NO es destructivo para datos custom del usuario: * el SAT. El auto-fill NO es destructivo para datos custom del usuario:
* solo sobreescribe campos si la CSF tiene un valor no-vacío. * solo sobreescribe campos si la CSF tiene un valor no-vacío.
*
* Incluye retry con backoff (3 intentos) para robustez ante timeouts
* transitorios del portal SAT (mantenimiento nocturno, congestión, etc.).
*/ */
export async function consultarConstancia(tenantId: string): Promise<ConstanciaRow> { export async function consultarConstancia(tenantId: string): Promise<ConstanciaRow> {
const fiel = await getDecryptedFiel(tenantId); const fiel = await getDecryptedFiel(tenantId);
@@ -55,72 +58,78 @@ export async function consultarConstancia(tenantId: string): Promise<ConstanciaR
}); });
if (!tenant) throw new Error('Tenant no encontrado'); if (!tenant) throw new Error('Tenant no encontrado');
const tempId = randomUUID(); const MAX_RETRIES = 3;
const tempDir = join(tmpdir(), `horux-csf-${tempId}`); const RETRY_DELAYS = [5_000, 15_000, 30_000]; // backoff
mkdirSync(tempDir, { recursive: true, mode: 0o700 });
const cerPath = join(tempDir, 'cert.cer');
const keyPath = join(tempDir, 'key.key');
try { for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
writeFileSync(cerPath, Buffer.from(fiel.cerContent, 'binary'), { mode: 0o600 }); const tempId = randomUUID();
writeFileSync(keyPath, Buffer.from(fiel.keyContent, 'binary'), { mode: 0o600 }); const tempDir = join(tmpdir(), `horux-csf-${tempId}`);
mkdirSync(tempDir, { recursive: true, mode: 0o700 });
const cerPath = join(tempDir, 'cert.cer');
const keyPath = join(tempDir, 'key.key');
// Headless por default. El fix de dispatchEvent en sat-csf-login cubre el
// caso donde el click sintético no dispara el handler del SAT. Si algún
// ambiente necesita ver el browser (debug), setear SAT_HEADLESS=false.
const headless = process.env.SAT_HEADLESS !== 'false';
const browser = await chromium.launch({
headless,
args: ['--disable-blink-features=AutomationControlled'],
ignoreDefaultArgs: ['--enable-automation'],
});
try { try {
const timeoutPromise = new Promise<never>((_, reject) => writeFileSync(cerPath, Buffer.from(fiel.cerContent, 'binary'), { mode: 0o600 });
setTimeout(() => reject(new Error('Timeout: proceso de CSF excedió 3 minutos')), PROCESS_TIMEOUT), writeFileSync(keyPath, Buffer.from(fiel.keyContent, 'binary'), { mode: 0o600 });
);
const resultPromise = (async () => { const headless = process.env.SAT_HEADLESS !== 'false';
const session = await loginSatCsf(browser, cerPath, keyPath, fiel.password, fiel.rfc); const browser = await chromium.launch({
const pdfBuffer = await extractCsfPdf(session); headless,
const csf = await parseCsfPdf(pdfBuffer); args: ['--disable-blink-features=AutomationControlled'],
ignoreDefaultArgs: ['--enable-automation'],
const pool = await tenantDb.getPool(tenantId, tenant.databaseName); });
const { rows } = await pool.query( try {
`INSERT INTO constancias_situacion_fiscal const timeoutPromise = new Promise<never>((_, reject) =>
(rfc, id_cif, razon_social, estatus_padron, fecha_emision, datos, pdf) setTimeout(() => reject(new Error('Timeout: proceso de CSF excedió 5 minutos')), 300_000),
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, rfc, id_cif, razon_social, estatus_padron, fecha_emision,
datos, fecha_consulta, created_at`,
[
csf.rfc,
csf.idCIF,
csf.razonSocial ?? [csf.nombre, csf.primerApellido, csf.segundoApellido].filter(Boolean).join(' ') ?? null,
csf.estatusPadron,
csf.lugarFechaEmision,
JSON.stringify(csf),
pdfBuffer,
],
); );
// Auto-fill domicilio del tenant + regímenes activos desde el CSF. const resultPromise = (async () => {
// Se hace después del INSERT para que si algo falla en la sincronización const session = await loginSatCsf(browser, cerPath, keyPath, fiel.password, fiel.rfc);
// la CSF ya quedó guardada y el usuario puede verla. const pdfBuffer = await extractCsfPdf(session);
await sincronizarDatosFiscales(tenantId, csf).catch(err => { const csf = await parseCsfPdf(pdfBuffer);
console.error(`[CSF] Error sincronizando datos fiscales para tenant ${tenantId}:`, err);
});
return rowToConstancia(rows[0]); const pool = await tenantDb.getPool(tenantId, tenant.databaseName);
})(); const { rows } = await pool.query(
`INSERT INTO constancias_situacion_fiscal
(rfc, id_cif, razon_social, estatus_padron, fecha_emision, datos, pdf)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, rfc, id_cif, razon_social, estatus_padron, fecha_emision,
datos, fecha_consulta, created_at`,
[
csf.rfc,
csf.idCIF,
csf.razonSocial ?? [csf.nombre, csf.primerApellido, csf.segundoApellido].filter(Boolean).join(' ') ?? null,
csf.estatusPadron,
csf.lugarFechaEmision,
JSON.stringify(csf),
pdfBuffer,
],
);
return await Promise.race([resultPromise, timeoutPromise]); await sincronizarDatosFiscales(tenantId, csf).catch(err => {
console.error(`[CSF] Error sincronizando datos fiscales para tenant ${tenantId}:`, err);
});
return rowToConstancia(rows[0]);
})();
return await Promise.race([resultPromise, timeoutPromise]);
} finally {
await browser.close();
}
} catch (err: any) {
const willRetry = attempt < MAX_RETRIES - 1;
console.error(`[CSF] Intento ${attempt + 1}/${MAX_RETRIES} falló para tenant ${tenantId}: ${err.message}${willRetry ? ` — reintentando en ${RETRY_DELAYS[attempt]}ms...` : ''}`);
if (!willRetry) throw err;
await new Promise(r => setTimeout(r, RETRY_DELAYS[attempt]));
} finally { } finally {
await browser.close(); try { unlinkSync(cerPath); } catch { /* ok */ }
try { unlinkSync(keyPath); } catch { /* ok */ }
try { rmdirSync(tempDir); } catch { /* ok */ }
} }
} finally {
try { unlinkSync(cerPath); } catch { /* ok */ }
try { unlinkSync(keyPath); } catch { /* ok */ }
try { rmdirSync(tempDir); } catch { /* ok */ }
} }
throw new Error('No debería llegar aquí');
} }
/** /**

View File

@@ -435,7 +435,7 @@ export async function createInvoiceContribuyente(
unit_key: item.unitKey || 'E48', unit_key: item.unitKey || 'E48',
unit_name: item.unitName || 'Servicio', unit_name: item.unitName || 'Servicio',
price: item.price, price: item.price,
tax_included: item.taxIncluded ?? true, tax_included: item.taxIncluded ?? false,
taxes: item.taxes?.map((t: any) => ({ taxes: item.taxes?.map((t: any) => ({
type: t.type, type: t.type,
rate: t.rate, rate: t.rate,
@@ -443,6 +443,7 @@ export async function createInvoiceContribuyente(
...(t.withholding ? { withholding: true } : {}), ...(t.withholding ? { withholding: true } : {}),
})) || [{ type: 'IVA', rate: 0.16 }], })) || [{ type: 'IVA', rate: 0.16 }],
}, },
...(data.cuentaPredial ? { property_tax_account: data.cuentaPredial } : {}),
})); }));
} }
@@ -457,6 +458,7 @@ export async function createInvoiceContribuyente(
if (data.series) invoicePayload.series = data.series; if (data.series) invoicePayload.series = data.series;
if (data.folioNumber) invoicePayload.folio_number = data.folioNumber; if (data.folioNumber) invoicePayload.folio_number = data.folioNumber;
if (data.fechaEmision) invoicePayload.date = data.fechaEmision;
if (data.relatedDocuments?.length) { if (data.relatedDocuments?.length) {
// Estructura SAT 4.0: agrupa N uuids por tipo de relación. Acepta tanto // Estructura SAT 4.0: agrupa N uuids por tipo de relación. Acepta tanto
@@ -542,3 +544,66 @@ async function ensureOrgLegalForEmit(
const payload = await buildLegalPayload(contrib, chosenTaxSystem, currentLegal); const payload = await buildLegalPayload(contrib, chosenTaxSystem, currentLegal);
await putOrgLegal(orgId, payload); await putOrgLegal(orgId, payload);
} }
// ── Personalización (logo, color) per-contribuyente ──
export async function getCustomizationContribuyente(
pool: Pool,
contribuyenteId: string,
): Promise<{ logoUrl?: string; color?: string } | null> {
const { rows } = await pool.query<{ facturapi_org_id: string }>(
'SELECT facturapi_org_id FROM facturapi_orgs WHERE contribuyente_id = $1 AND active = true',
[contribuyenteId],
);
if (rows.length === 0) return null;
const userClient = getUserClient();
try {
const org = await userClient.organizations.retrieve(rows[0].facturapi_org_id);
return {
logoUrl: org.customization?.has_logo ? (org.logo_url ?? undefined) : undefined,
color: org.customization?.color || undefined,
};
} catch { return null; }
}
export async function uploadLogoContribuyente(
pool: Pool,
contribuyenteId: string,
logoBase64: string,
): Promise<{ success: boolean; message: string }> {
const { rows } = await pool.query<{ facturapi_org_id: string }>(
'SELECT facturapi_org_id FROM facturapi_orgs WHERE contribuyente_id = $1 AND active = true',
[contribuyenteId],
);
if (rows.length === 0) throw new Error('Organización no configurada');
const userClient = getUserClient();
try {
const buffer = Buffer.from(logoBase64, 'base64');
await userClient.organizations.uploadLogo(rows[0].facturapi_org_id, buffer);
return { success: true, message: 'Logo subido correctamente' };
} catch (error: any) {
return { success: false, message: error.message || 'Error al subir logo' };
}
}
export async function updateColorContribuyente(
pool: Pool,
contribuyenteId: string,
color: string,
): Promise<{ success: boolean; message: string }> {
const { rows } = await pool.query<{ facturapi_org_id: string }>(
'SELECT facturapi_org_id FROM facturapi_orgs WHERE contribuyente_id = $1 AND active = true',
[contribuyenteId],
);
if (rows.length === 0) throw new Error('Organización no configurada');
const userClient = getUserClient();
try {
await userClient.organizations.updateCustomization(rows[0].facturapi_org_id, { color });
return { success: true, message: 'Color actualizado correctamente' };
} catch (error: any) {
return { success: false, message: error.message || 'Error al actualizar color' };
}
}

View File

@@ -1,4 +1,5 @@
import type { Pool } from 'pg'; import type { Pool } from 'pg';
import { prisma } from '../config/database.js';
export interface CreateContribuyenteData { export interface CreateContribuyenteData {
rfc: string; rfc: string;
@@ -23,7 +24,61 @@ export interface ContribuyenteRow {
domicilio: Record<string, unknown> | null; domicilio: Record<string, unknown> | null;
} }
export async function listContribuyentes(pool: Pool, entidadIds?: string[]): Promise<ContribuyenteRow[]> { async function fetchTenantFiscalData(tenantId: string) {
const tenant = await prisma.tenant.findUnique({
where: { id: tenantId },
select: {
rfc: true,
codigoPostal: true,
calle: true,
numExterior: true,
numInterior: true,
colonia: true,
ciudad: true,
municipio: true,
estado: true,
telefono: true,
},
});
if (!tenant) return null;
const regimenes = await prisma.tenantRegimenActivo.findMany({
where: { tenantId },
select: { regimen: { select: { clave: true } } },
});
const regimenFiscal = regimenes.map(r => r.regimen.clave).join(',') || null;
const hasAnyAddress = tenant.calle || tenant.colonia || tenant.ciudad || tenant.municipio || tenant.estado || tenant.codigoPostal;
const domicilio = hasAnyAddress
? {
calle: tenant.calle || '',
numExterior: tenant.numExterior || '',
numInterior: tenant.numInterior || '',
colonia: tenant.colonia || '',
ciudad: tenant.ciudad || '',
municipio: tenant.municipio || '',
estado: tenant.estado || '',
codigoPostal: tenant.codigoPostal || '',
telefono: tenant.telefono || '',
}
: null;
return { tenantRfc: tenant.rfc, regimenFiscal, codigoPostal: tenant.codigoPostal, domicilio };
}
function mergeContribuyenteWithTenant(
row: ContribuyenteRow,
tenantData: NonNullable<Awaited<ReturnType<typeof fetchTenantFiscalData>>>
): ContribuyenteRow {
return {
...row,
regimenFiscal: row.regimenFiscal || tenantData.regimenFiscal,
codigoPostal: row.codigoPostal || tenantData.codigoPostal,
domicilio: row.domicilio || tenantData.domicilio,
};
}
export async function listContribuyentes(pool: Pool, entidadIds?: string[], tenantId?: string): Promise<ContribuyenteRow[]> {
let query = ` let query = `
SELECT SELECT
e.id, e.tipo, e.nombre, e.identificador, e.id, e.tipo, e.nombre, e.identificador,
@@ -45,10 +100,20 @@ export async function listContribuyentes(pool: Pool, entidadIds?: string[]): Pro
query += ' ORDER BY e.created_at DESC'; query += ' ORDER BY e.created_at DESC';
const { rows } = await pool.query(query, params); const { rows } = await pool.query(query, params);
return rows;
if (!tenantId) return rows;
const tenantData = await fetchTenantFiscalData(tenantId);
if (!tenantData) return rows;
return rows.map((r: ContribuyenteRow) => {
if (r.rfc !== tenantData.tenantRfc) return r;
if (r.regimenFiscal && r.codigoPostal && r.domicilio) return r;
return mergeContribuyenteWithTenant(r, tenantData);
});
} }
export async function getContribuyenteById(pool: Pool, id: string): Promise<ContribuyenteRow | null> { export async function getContribuyenteById(pool: Pool, id: string, tenantId?: string): Promise<ContribuyenteRow | null> {
const { rows } = await pool.query(` const { rows } = await pool.query(`
SELECT SELECT
e.id, e.tipo, e.nombre, e.identificador, e.id, e.tipo, e.nombre, e.identificador,
@@ -60,7 +125,14 @@ export async function getContribuyenteById(pool: Pool, id: string): Promise<Cont
JOIN contribuyentes c ON c.entidad_id = e.id JOIN contribuyentes c ON c.entidad_id = e.id
WHERE e.id = $1 WHERE e.id = $1
`, [id]); `, [id]);
return rows[0] ?? null; const row = rows[0] ?? null;
if (!row || !tenantId) return row;
const tenantData = await fetchTenantFiscalData(tenantId);
if (!tenantData || row.rfc !== tenantData.tenantRfc) return row;
if (row.regimenFiscal && row.codigoPostal && row.domicilio) return row;
return mergeContribuyenteWithTenant(row, tenantData);
} }
export async function createContribuyente(pool: Pool, data: CreateContribuyenteData): Promise<ContribuyenteRow> { export async function createContribuyente(pool: Pool, data: CreateContribuyenteData): Promise<ContribuyenteRow> {

View File

@@ -109,13 +109,13 @@ export const GRUPO_PM_OTROS = ['601', '603', '607', '608', '610', '611', '614',
const TODOS_REGIMENES = [...GRUPO_PF_EMPRESARIAL, ...GRUPO_SUELDOS, ...GRUPO_PM_OTROS]; const TODOS_REGIMENES = [...GRUPO_PF_EMPRESARIAL, ...GRUPO_SUELDOS, ...GRUPO_PM_OTROS];
// Filtro de fecha por rango — normal o conciliación // Filtro de fecha por rango — normal o conciliación
const FECHA_RANGO = `fecha_emision >= $1::date AND fecha_emision < ($2::date + interval '1 day')`; const FECHA_RANGO = `COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') >= $1::date AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') < ($2::date + interval '1 day')`;
// Para CFDIs tipo P (complementos de pago): el ingreso/gasto se reconoce en la // Para CFDIs tipo P (complementos de pago): el ingreso/gasto se reconoce en la
// fecha_pago_p (cuándo el cliente realmente pagó), no cuando se emitió el // fecha_pago_p (cuándo el cliente realmente pagó), no cuando se emitió el
// complemento — el CFDI P puede emitirse hasta el día 5 del mes siguiente al // complemento — el CFDI P puede emitirse hasta el día 5 del mes siguiente al
// pago, o incluso después, y cruzar meses (ej. pago de noviembre 2024 con // pago, o incluso después, y cruzar meses (ej. pago de noviembre 2024 con
// complemento emitido en mayo 2025). // complemento emitido en mayo 2025).
const FECHA_PAGO_RANGO = `fecha_pago_p >= $1::date AND fecha_pago_p < ($2::date + interval '1 day')`; const FECHA_PAGO_RANGO = `(fecha_pago_p - interval '1 hour') >= $1::date AND (fecha_pago_p - interval '1 hour') < ($2::date + interval '1 day')`;
const FECHA_RANGO_CONCILIACION = `id_conciliacion IS NOT NULL AND id_conciliacion IN ( const FECHA_RANGO_CONCILIACION = `id_conciliacion IS NOT NULL AND id_conciliacion IN (
SELECT id FROM conciliaciones WHERE fecha_de_pago >= $1::date AND fecha_de_pago < ($2::date + interval '1 day') SELECT id FROM conciliaciones WHERE fecha_de_pago >= $1::date AND fecha_de_pago < ($2::date + interval '1 day')
)`; )`;
@@ -989,14 +989,14 @@ export async function calcularIvaBalancePorRegimen(
AND e.status NOT IN ('Cancelado', '0') AND e.status NOT IN ('Cancelado', '0')
AND ${esEmisor.replace(/\brfc_emisor\b/g, 'e.rfc_emisor')} AND ${esEmisor.replace(/\brfc_emisor\b/g, 'e.rfc_emisor')}
AND LOWER(i.uuid) = ANY(string_to_array(LOWER(e.cfdis_relacionados), '|')) AND LOWER(i.uuid) = ANY(string_to_array(LOWER(e.cfdis_relacionados), '|'))
AND date_trunc('month', e.fecha_emision) = date_trunc('month', i.fecha_emision) AND date_trunc('month', COALESCE(e.fecha_efectiva, e.fecha_emision - interval '1 hour')) = date_trunc('month', COALESCE(i.fecha_efectiva, i.fecha_emision - interval '1 hour'))
)), 0) as monto )), 0) as monto
FROM cfdis i FROM cfdis i
WHERE ${esEmisor.replace(/\brfc_emisor\b/g, 'i.rfc_emisor')} WHERE ${esEmisor.replace(/\brfc_emisor\b/g, 'i.rfc_emisor')}
AND i.tipo_comprobante = 'I' AND i.metodo_pago = 'PPD' AND i.tipo_comprobante = 'I' AND i.metodo_pago = 'PPD'
AND COALESCE(i.cfdi_tipo_relacion, '') = '07' AND COALESCE(i.cfdi_tipo_relacion, '') = '07'
AND i.status NOT IN ('Cancelado','0') AND i.status NOT IN ('Cancelado','0')
AND ${FR.replace(/\bfecha_emision\b/g, 'i.fecha_emision')} AND ${FR.replace(/\bfecha_efectiva\b/g, 'i.fecha_efectiva').replace(/\bfecha_emision\b/g, 'i.fecha_emision')}
AND i.regimen_fiscal_emisor = ANY($3) AND i.regimen_fiscal_emisor = ANY($3)
GROUP BY i.regimen_fiscal_emisor GROUP BY i.regimen_fiscal_emisor
`, [fechaInicio, fechaFin, TODOS_REGIMENES]); `, [fechaInicio, fechaFin, TODOS_REGIMENES]);
@@ -1012,14 +1012,14 @@ export async function calcularIvaBalancePorRegimen(
AND e.status NOT IN ('Cancelado', '0') AND e.status NOT IN ('Cancelado', '0')
AND ${esReceptor.replace(/\brfc_receptor\b/g, 'e.rfc_receptor')} AND ${esReceptor.replace(/\brfc_receptor\b/g, 'e.rfc_receptor')}
AND LOWER(i.uuid) = ANY(string_to_array(LOWER(e.cfdis_relacionados), '|')) AND LOWER(i.uuid) = ANY(string_to_array(LOWER(e.cfdis_relacionados), '|'))
AND date_trunc('month', e.fecha_emision) = date_trunc('month', i.fecha_emision) AND date_trunc('month', COALESCE(e.fecha_efectiva, e.fecha_emision - interval '1 hour')) = date_trunc('month', COALESCE(i.fecha_efectiva, i.fecha_emision - interval '1 hour'))
)), 0) as monto )), 0) as monto
FROM cfdis i FROM cfdis i
WHERE ${esReceptor.replace(/\brfc_receptor\b/g, 'i.rfc_receptor')} WHERE ${esReceptor.replace(/\brfc_receptor\b/g, 'i.rfc_receptor')}
AND i.tipo_comprobante = 'I' AND i.metodo_pago = 'PPD' AND i.tipo_comprobante = 'I' AND i.metodo_pago = 'PPD'
AND COALESCE(i.cfdi_tipo_relacion, '') = '07' AND COALESCE(i.cfdi_tipo_relacion, '') = '07'
AND i.status NOT IN ('Cancelado','0') AND i.status NOT IN ('Cancelado','0')
AND ${FR.replace(/\bfecha_emision\b/g, 'i.fecha_emision')} AND ${FR.replace(/\bfecha_efectiva\b/g, 'i.fecha_efectiva').replace(/\bfecha_emision\b/g, 'i.fecha_emision')}
AND i.regimen_fiscal_receptor = ANY($3) AND i.regimen_fiscal_receptor = ANY($3)
GROUP BY i.regimen_fiscal_receptor GROUP BY i.regimen_fiscal_receptor
`, [fechaInicio, fechaFin, TODOS_REGIMENES]); `, [fechaInicio, fechaFin, TODOS_REGIMENES]);
@@ -1107,10 +1107,21 @@ export async function getKpis(
const ctx = await resolveContribuyenteContext(pool, tenantId, contribuyenteId); const ctx = await resolveContribuyenteContext(pool, tenantId, contribuyenteId);
const esEmisor = ctx.esEmisor; const esEmisor = ctx.esEmisor;
const esReceptor = ctx.esReceptor; const esReceptor = ctx.esReceptor;
const ingresosData = await calcularIngresosPorRegimen(pool, tenantId, fechaInicio, fechaFin, undefined, undefined, conciliacion, contribuyenteId); const [
const egresosData = await calcularEgresosPorRegimen(pool, tenantId, fechaInicio, fechaFin, undefined, undefined, conciliacion, contribuyenteId); ingresosData,
const adquisicionData = await calcularAdquisicionesMercancias(pool, tenantId, fechaInicio, fechaFin, conciliacion, contribuyenteId); egresosData,
const ivaData = await calcularIvaBalancePorRegimen(pool, tenantId, fechaInicio, fechaFin, undefined, undefined, conciliacion, contribuyenteId); adquisicionData,
ivaData,
ncsEmitidasData,
ncsRecibidasData,
] = await Promise.all([
calcularIngresosPorRegimen(pool, tenantId, fechaInicio, fechaFin, undefined, undefined, conciliacion, contribuyenteId),
calcularEgresosPorRegimen(pool, tenantId, fechaInicio, fechaFin, undefined, undefined, conciliacion, contribuyenteId),
calcularAdquisicionesMercancias(pool, tenantId, fechaInicio, fechaFin, conciliacion, contribuyenteId),
calcularIvaBalancePorRegimen(pool, tenantId, fechaInicio, fechaFin, undefined, undefined, conciliacion, contribuyenteId),
calcularNcsEmitidasPorRegimen(pool, tenantId, fechaInicio, fechaFin, undefined, undefined, conciliacion, contribuyenteId),
calcularNcsRecibidasPorRegimen(pool, tenantId, fechaInicio, fechaFin, undefined, undefined, conciliacion, contribuyenteId),
]);
// IVA a favor año actual: desde enero del año en curso // IVA a favor año actual: desde enero del año en curso
const ivaAFavorAcumulado = await calcularIvaAFavorAcumulado(pool, tenantId, fechaFin, undefined, conciliacion, contribuyenteId); const ivaAFavorAcumulado = await calcularIvaAFavorAcumulado(pool, tenantId, fechaFin, undefined, conciliacion, contribuyenteId);
@@ -1163,6 +1174,10 @@ export async function getKpis(
cfdisEmitidosPorRegimen: emitidosPorRegimen, cfdisEmitidosPorRegimen: emitidosPorRegimen,
cfdisRecibidos: recibidosPorRegimen.reduce((s: number, r: any) => s + r.total, 0), cfdisRecibidos: recibidosPorRegimen.reduce((s: number, r: any) => s + r.total, 0),
cfdisRecibidosPorRegimen: recibidosPorRegimen, cfdisRecibidosPorRegimen: recibidosPorRegimen,
ncsEmitidas: ncsEmitidasData.total,
ncsEmitidasPorRegimen: ncsEmitidasData.porRegimen,
ncsRecibidas: ncsRecibidasData.total,
ncsRecibidasPorRegimen: ncsRecibidasData.porRegimen,
}; };
} }

View File

@@ -1,4 +1,38 @@
import type { Pool } from 'pg'; import type { Pool } from 'pg';
import { createEvidencia } from './obligacion-evidencias.service.js';
function normalize(s: string): string {
return s
.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/[.,;:()]/g, '')
.trim();
}
/**
* Dadas las obligaciones seleccionadas para una declaración, infiere los
* impuestos que cubre. Se usa para mantener la resolución de alertas legacy
* (decl-*, pago-*) sin exponer el campo en la UI.
*/
function inferirImpuestosDeObligaciones(
obligaciones: Array<{ id: string; nombre: string; catalogoId?: string | null }>,
): Impuesto[] {
const set = new Set<Impuesto>();
for (const ob of obligaciones) {
const nombre = normalize(ob.nombre);
const catalogoId = normalize(ob.catalogoId || '');
if (nombre.includes('diot') || catalogoId.includes('diot')) {
set.add('DIOT');
} else if (nombre.includes('iva') || catalogoId.includes('iva')) {
set.add('IVA');
}
if (nombre.includes('isr') || catalogoId.includes('isr')) set.add('ISR');
if (nombre.includes('ieps') || catalogoId.includes('ieps')) set.add('IEPS');
if (nombre.includes('isn') || catalogoId.includes('isn')) set.add('ISN');
if (nombre.includes('ish') || catalogoId.includes('ish')) set.add('ISH');
}
return Array.from(set);
}
// Mapeo: impuesto de la declaración → reglas para matchear obligaciones del // Mapeo: impuesto de la declaración → reglas para matchear obligaciones del
// contribuyente. `include` son substrings que DEBE contener el nombre de la // contribuyente. `include` son substrings que DEBE contener el nombre de la
@@ -9,7 +43,8 @@ const IMPUESTO_A_OBLIGACION_KEYWORDS: Record<string, { include: string[]; exclud
IVA: { include: ['iva'], exclude: ['diot', 'proveedores de iva', 'informativa'] }, IVA: { include: ['iva'], exclude: ['diot', 'proveedores de iva', 'informativa'] },
ISR: { include: ['isr'], exclude: ['retenciones', 'asimilados a salarios'] }, ISR: { include: ['isr'], exclude: ['retenciones', 'asimilados a salarios'] },
IEPS: { include: ['ieps'], exclude: [] }, IEPS: { include: ['ieps'], exclude: [] },
SUELDOS: { include: ['sueldos', 'salarios', 'nómina'], exclude: [] }, ISN: { include: ['isn', 'sueldos', 'salarios', 'nómina'], exclude: [] },
ISH: { include: [], exclude: [] },
DIOT: { include: ['diot', 'proveedores de iva'], exclude: [] }, DIOT: { include: ['diot', 'proveedores de iva'], exclude: [] },
OTRO: { include: [], exclude: [] }, OTRO: { include: [], exclude: [] },
}; };
@@ -24,17 +59,28 @@ const IMPUESTO_A_OBLIGACION_KEYWORDS: Record<string, { include: string[]; exclud
* periodo sigue marcado completado — el usuario decidirá si re-abrirlo * periodo sigue marcado completado — el usuario decidirá si re-abrirlo
* manualmente. * manualmente.
*/ */
async function completarObligacionesPorDeclaracion( /**
* Al subir una declaración o comprobante de pago, registra una evidencia para
* cada obligación del contribuyente que corresponda al impuesto declarado.
*
* - Obligaciones informativas (`requierePago = false`) se marcan completadas al
* recibir cualquier documento de declaración/acuse.
* - Obligaciones de pago (`requierePago = true`) se marcan completadas solo al
* recibir un comprobante de pago (`tipo_documento = 'pago'`).
*/
async function registrarEvidenciasPorDeclaracion(
pool: Pool, pool: Pool,
contribuyenteId: string, contribuyenteId: string,
impuestos: string[], impuestos: string[],
periodo: string, periodo: string,
/** UUID del usuario que subió la declaración (obligacion_periodos.completada_por es uuid). */ /** UUID del usuario que subió el documento. */
completadaPor: string, subidoPor: string,
declaracionId: number, pdfBase64: string,
/** Periodicidad de la declaración. Si no se provee, se asume 'mensual'. */ pdfFilename: string,
tipoDocumento: 'declaracion' | 'pago',
/** Periodicidad de la declaración. Si no se provee, asume 'mensual'. */
periodicidad: string = 'mensual', periodicidad: string = 'mensual',
): Promise<number> { ): Promise<{ count: number; obligacionesAfectadas: string[] }> {
// Get active obligations for this contribuyente (incluye frecuencia para filtrar) // Get active obligations for this contribuyente (incluye frecuencia para filtrar)
const { rows: obligaciones } = await pool.query<{ id: string; nombre: string; frecuencia: string | null }>( const { rows: obligaciones } = await pool.query<{ id: string; nombre: string; frecuencia: string | null }>(
`SELECT id, nombre, frecuencia FROM obligaciones_contribuyente WHERE contribuyente_id = $1 AND activa = true`, `SELECT id, nombre, frecuencia FROM obligaciones_contribuyente WHERE contribuyente_id = $1 AND activa = true`,
@@ -42,6 +88,7 @@ async function completarObligacionesPorDeclaracion(
); );
let count = 0; let count = 0;
const obligacionesAfectadas: string[] = [];
for (const impuesto of impuestos) { for (const impuesto of impuestos) {
const rules = IMPUESTO_A_OBLIGACION_KEYWORDS[impuesto]; const rules = IMPUESTO_A_OBLIGACION_KEYWORDS[impuesto];
@@ -54,33 +101,109 @@ async function completarObligacionesPorDeclaracion(
if (!matches) continue; if (!matches) continue;
// Filtro por periodicidad/frecuencia: una declaración mensual no debe // Filtro por periodicidad/frecuencia: una declaración mensual no debe
// cerrar obligaciones anuales del mismo impuesto (ej. ISR mensual no // cerrar obligaciones anuales del mismo impuesto.
// cubre "Declaración anual de ISR"). Si la obligación tiene frecuencia
// explícita y no coincide con la periodicidad de la declaración, skip.
// `eventual` obligaciones no se tocan automáticamente.
const obFrec = (ob.frecuencia || '').toLowerCase(); const obFrec = (ob.frecuencia || '').toLowerCase();
if (obFrec === 'eventual') continue; if (obFrec === 'eventual') continue;
if (obFrec && obFrec !== periodicidad.toLowerCase()) continue; if (obFrec && obFrec !== periodicidad.toLowerCase()) continue;
// Mark obligation as completed for this period, with FK a la declaración await createEvidencia(pool, {
await pool.query(` obligacionId: ob.id,
INSERT INTO obligacion_periodos (obligacion_id, periodo, completada, completada_at, completada_por, notas, declaracion_id) periodo,
VALUES ($1, $2, true, now(), $3, $4, $5) contribuyenteId,
ON CONFLICT (obligacion_id, periodo) tipoDocumento,
DO UPDATE SET completada = true, completada_at = now(), completada_por = $3, declaracion_id = $5 pdfBase64,
`, [ob.id, periodo, completadaPor, `Declaración ${impuesto} subida`, declaracionId]); pdfFilename,
notas: `${tipoDocumento === 'pago' ? 'Pago' : 'Declaración'} ${impuesto}`,
// Resolve the ob-* alert for this obligation+period subidoPor,
await pool.query( });
`UPDATE alertas SET resuelta = true WHERE tipo = $1 AND resuelta = false`,
[`ob-${ob.id}-${periodo}`],
);
if (!obligacionesAfectadas.includes(ob.id)) obligacionesAfectadas.push(ob.id);
count++; count++;
} }
} }
return count; return { count, obligacionesAfectadas };
}
/**
* Cuando una declaración tiene monto $0, no se requiere comprobante de pago.
* Esta función marca `pago_presentado = true` (y `completada = true`) en los
* periodos de las obligaciones afectadas para reflejar que el pago está saldado.
*/
async function confirmarPagoPeriodoSinComprobante(
pool: Pool,
obligacionesAfectadas: string[],
periodo: string,
userId: string,
): Promise<void> {
const now = new Date();
for (const obligacionId of obligacionesAfectadas) {
await pool.query(
`INSERT INTO obligacion_periodos
(obligacion_id, periodo, declaracion_presentada, pago_presentado, completada, completada_at, completada_por)
VALUES ($1, $2, true, true, true, $3, $4)
ON CONFLICT (obligacion_id, periodo)
DO UPDATE SET
pago_presentado = true,
completada = true,
completada_at = COALESCE(obligacion_periodos.completada_at, $3),
completada_por = COALESCE(obligacion_periodos.completada_por, $4)`,
[obligacionId, periodo, now, userId],
);
// Resolver alerta ob-* si existe
await pool.query(
`UPDATE alertas SET resuelta = true WHERE tipo = $1 AND resuelta = false`,
[`ob-${obligacionId}-${periodo}`],
);
}
}
/**
* Registra una evidencia por cada obligación seleccionada.
* - Obligaciones informativas se completan con `declaracion`/`acuse`/`complemento`.
* - Obligaciones de pago requieren evidencia `pago` para cerrarse.
*/
async function registrarEvidenciasPorObligaciones(
pool: Pool,
obligaciones: Array<{ id: string; nombre: string; catalogoId?: string | null }>,
contribuyenteId: string,
periodo: string,
subidoPor: string,
pdfBase64: string,
pdfFilename: string,
tipoDocumento: 'declaracion' | 'pago',
notas?: string,
): Promise<string[]> {
const afectadas: string[] = [];
for (const ob of obligaciones) {
await createEvidencia(pool, {
obligacionId: ob.id,
periodo,
contribuyenteId,
tipoDocumento,
pdfBase64,
pdfFilename,
notas: notas || `${tipoDocumento === 'pago' ? 'Comprobante de pago' : 'Declaración'}: ${ob.nombre}`,
subidoPor,
});
afectadas.push(ob.id);
}
return afectadas;
}
async function getObligacionesPorIds(
pool: Pool,
contribuyenteId: string,
obligacionesIds: string[],
): Promise<Array<{ id: string; nombre: string; catalogoId: string | null }>> {
const { rows } = await pool.query<{ id: string; nombre: string; catalogo_id: string | null }>(
`SELECT id, nombre, catalogo_id
FROM obligaciones_contribuyente
WHERE contribuyente_id = $1 AND id = ANY($2::uuid[]) AND activa = true`,
[contribuyenteId, obligacionesIds],
);
return rows.map(r => ({ id: r.id, nombre: r.nombre, catalogoId: r.catalogo_id }));
} }
/** /**
@@ -93,9 +216,9 @@ async function completarObligacionesPorDeclaracion(
* adicional, no reemplaza. * adicional, no reemplaza.
*/ */
export type Impuesto = 'IVA' | 'ISR' | 'IEPS' | 'SUELDOS' | 'DIOT' | 'OTRO'; export type Impuesto = 'IVA' | 'ISR' | 'IEPS' | 'ISN' | 'DIOT' | 'OTRO' | 'ISH';
export type Periodicidad = 'mensual' | 'bimestral' | 'trimestral' | 'semestral' | 'anual'; export type Periodicidad = 'mensual' | 'bimestral' | 'trimestral' | 'cuatrimestral' | 'semestral' | 'anual';
export interface DeclaracionRow { export interface DeclaracionRow {
id: number; id: number;
@@ -123,17 +246,19 @@ const IMPUESTO_A_PREFIJO_DECL: Record<string, string[]> = {
IVA: ['decl-iva'], IVA: ['decl-iva'],
ISR: ['decl-isr'], ISR: ['decl-isr'],
IEPS: ['decl-ieps'], IEPS: ['decl-ieps'],
SUELDOS: ['decl-sueldos'], ISN: ['decl-isn'],
DIOT: ['diot'], DIOT: ['diot'],
OTRO: [], OTRO: [],
ISH: [],
}; };
const IMPUESTO_A_PREFIJO_PAGO: Record<string, string[]> = { const IMPUESTO_A_PREFIJO_PAGO: Record<string, string[]> = {
IVA: ['pago-iva'], IVA: ['pago-iva'],
ISR: ['pago-isr'], ISR: ['pago-isr'],
IEPS: ['pago-ieps'], IEPS: ['pago-ieps'],
SUELDOS: [], // sueldos solo es declaración informativa, no tiene pago provisional ISN: [], // ISN solo es declaración informativa, no tiene pago provisional
DIOT: [], DIOT: [],
OTRO: [], OTRO: [],
ISH: [],
}; };
/** /**
@@ -229,7 +354,10 @@ export async function createDeclaracion(
mes: number; mes: number;
tipo: 'normal' | 'complementaria'; tipo: 'normal' | 'complementaria';
periodicidad?: Periodicidad; periodicidad?: Periodicidad;
impuestos: string[]; /** Legacy: se infiere de obligacionesIds si no se envía. */
impuestos?: string[];
/** Obligaciones fiscales que cubre esta declaración. */
obligacionesIds?: string[];
montoPago?: number | null; montoPago?: number | null;
pdfBase64: string; // PDF de la declaración (base64) pdfBase64: string; // PDF de la declaración (base64)
pdfFilename: string; pdfFilename: string;
@@ -250,6 +378,16 @@ export async function createDeclaracion(
// If monto_pago is exactly 0, auto-mark as paid (no payment receipt needed) // If monto_pago is exactly 0, auto-mark as paid (no payment receipt needed)
const pagadoAt = montoPago === 0 ? new Date() : null; const pagadoAt = montoPago === 0 ? new Date() : null;
// Resolvemos obligaciones e impuestos.
let obligacionesSeleccionadas: Array<{ id: string; nombre: string; catalogoId: string | null }> = [];
let impuestos: string[] = data.impuestos ?? [];
if (data.contribuyenteId && data.obligacionesIds && data.obligacionesIds.length > 0) {
obligacionesSeleccionadas = await getObligacionesPorIds(pool, data.contribuyenteId, data.obligacionesIds);
if (impuestos.length === 0) {
impuestos = inferirImpuestosDeObligaciones(obligacionesSeleccionadas);
}
}
try { try {
const { rows } = await pool.query( const { rows } = await pool.query(
`INSERT INTO declaraciones_provisionales `INSERT INTO declaraciones_provisionales
@@ -259,46 +397,55 @@ export async function createDeclaracion(
RETURNING id, año, mes, tipo, periodicidad, impuestos, monto_pago, pdf_filename, RETURNING id, año, mes, tipo, periodicidad, impuestos, monto_pago, pdf_filename,
pdf_liga_pago_filename, pdf_pago_filename, pagado_at, creado_por, notas, pdf_liga_pago_filename, pdf_pago_filename, pagado_at, creado_por, notas,
created_at, updated_at`, created_at, updated_at`,
[data.año, data.mes, data.tipo, periodicidad, data.impuestos, montoPago, [data.año, data.mes, data.tipo, periodicidad, impuestos, montoPago,
buf, data.pdfFilename, ligaBuf, data.ligaPagoFilename ?? null, buf, data.pdfFilename, ligaBuf, data.ligaPagoFilename ?? null,
data.notas ?? null, data.creadoPor, pagadoAt, data.contribuyenteId ?? null], data.notas ?? null, data.creadoPor, pagadoAt, data.contribuyenteId ?? null],
); );
const declaracion = rowToDeclaracion(rows[0]); const declaracion = rowToDeclaracion(rows[0]);
// Auto-resolver alertas. Reglas: // Guardar relación con obligaciones para que el comprobante de pago
// - tipo='normal': resuelve alertas de declaración (decl-*) del mes. // posterior se aplique a las mismas obligaciones.
// El pago se resuelve por separado al subir comprobante. if (obligacionesSeleccionadas.length > 0) {
// - tipo='complementaria': sustituye a la normal en términos de const values = obligacionesSeleccionadas.map((_, i) => `($1, $${i + 2})`).join(',');
// obligación de pago — al subirla se resuelven AMBAS (decl-* y await pool.query(
// pago-*) porque el cliente pagará usando la complementaria, `INSERT INTO declaracion_obligaciones (declaracion_id, obligacion_id) VALUES ${values}`,
// no la normal. La alerta de declaración ya estaría resuelta [declaracion.id, ...obligacionesSeleccionadas.map(o => o.id)],
// si la normal se subió antes; el resolver es idempotente. );
const prefijosDecl = data.impuestos.flatMap(i => IMPUESTO_A_PREFIJO_DECL[i] || []); }
// Auto-resolver alertas legacy (decl-*, pago-*).
const prefijosDecl = impuestos.flatMap(i => IMPUESTO_A_PREFIJO_DECL[i] || []);
let alertasResueltas = await resolverAlertasPorPeriodo(pool, prefijosDecl, data.año, data.mes); let alertasResueltas = await resolverAlertasPorPeriodo(pool, prefijosDecl, data.año, data.mes);
if (data.tipo === 'complementaria' || montoPago === 0) { if (data.tipo === 'complementaria' || montoPago === 0) {
// complementaria: sustituye normal para pago → resolver ambas const prefijosPago = impuestos.flatMap(i => IMPUESTO_A_PREFIJO_PAGO[i] || []);
// monto 0: nada que pagar → resolver alertas de pago también
const prefijosPago = data.impuestos.flatMap(i => IMPUESTO_A_PREFIJO_PAGO[i] || []);
alertasResueltas += await resolverAlertasPorPeriodo(pool, prefijosPago, data.año, data.mes); alertasResueltas += await resolverAlertasPorPeriodo(pool, prefijosPago, data.año, data.mes);
} }
// Auto-complete obligaciones del contribuyente SOLO si la declaración // Registrar evidencias de declaración en las obligaciones seleccionadas.
// también cubre el pago (complementaria sustituye a la normal para el // Fallback legacy: si no se enviaron obligaciones, se usa el keyword matching
// pago; monto=0 significa "nada que pagar"). Una declaración normal con // anterior a partir de impuestos.
// monto>0 solo presenta el acuse — la obligación de pago sigue abierta let obligacionesAfectadas: string[] = obligacionesSeleccionadas.map(o => o.id);
// y se marca completada hasta que se suba el comprobante via if (data.contribuyenteId && data.creadoPorUserId) {
// `uploadComprobantePago`. Esto mantiene las alertas `pago-*` y `ob-*` const periodo = `${data.año}-${String(data.mes).padStart(2, '0')}`;
// visibles hasta que realmente se cierre el ciclo.
const cubrePago = data.tipo === 'complementaria' || montoPago === 0; if (obligacionesSeleccionadas.length > 0) {
if (data.contribuyenteId && cubrePago) { await registrarEvidenciasPorObligaciones(
if (!data.creadoPorUserId) { pool, obligacionesSeleccionadas, data.contribuyenteId, periodo, data.creadoPorUserId,
console.warn('[createDeclaracion] Sin creadoPorUserId — no se auto-completan obligaciones del contribuyente'); data.pdfBase64, data.pdfFilename, 'declaracion', data.notas,
} else {
const periodo = `${data.año}-${String(data.mes).padStart(2, '0')}`;
alertasResueltas += await completarObligacionesPorDeclaracion(
pool, data.contribuyenteId, data.impuestos, periodo, data.creadoPorUserId, declaracion.id, periodicidad,
); );
} else if (impuestos.length > 0) {
const { obligacionesAfectadas: afectadas } = await registrarEvidenciasPorDeclaracion(
pool, data.contribuyenteId, impuestos, periodo, data.creadoPorUserId,
data.pdfBase64, data.pdfFilename, 'declaracion', periodicidad,
);
obligacionesAfectadas = afectadas;
}
// Si la declaración es por $0, no se requiere comprobante de pago:
// marcar el pago como presentado automáticamente.
if (montoPago === 0 && obligacionesAfectadas.length > 0) {
await confirmarPagoPeriodoSinComprobante(pool, obligacionesAfectadas, periodo, data.creadoPorUserId);
} }
} }
@@ -337,20 +484,35 @@ export async function uploadComprobantePago(
const row = rows[0]; const row = rows[0];
const declaracion = rowToDeclaracion(row); const declaracion = rowToDeclaracion(row);
// Auto-resolver alertas de pago para los impuestos del periodo // Auto-resolver alertas de pago legacy.
const prefijosPago = declaracion.impuestos.flatMap(i => IMPUESTO_A_PREFIJO_PAGO[i] || []); const prefijosPago = declaracion.impuestos.flatMap(i => IMPUESTO_A_PREFIJO_PAGO[i] || []);
let alertasResueltas = await resolverAlertasPorPeriodo(pool, prefijosPago, declaracion.año, declaracion.mes); let alertasResueltas = await resolverAlertasPorPeriodo(pool, prefijosPago, declaracion.año, declaracion.mes);
// Al subirse el comprobante de pago, la obligación ahora SÍ está completada // Registrar evidencias de pago en las obligaciones vinculadas a esta declaración.
// (declaración + pago). Marcar `obligacion_periodos.completada=true` y // Fallback legacy: si no hay relaciones, se usa keyword matching por impuestos.
// resolver los `ob-*` alerts. Requires contribuyenteId (guardado en la
// declaración) y userId (del caller).
if (row.contribuyente_id && data.uploadedByUserId) { if (row.contribuyente_id && data.uploadedByUserId) {
const periodo = `${declaracion.año}-${String(declaracion.mes).padStart(2, '0')}`; const periodo = `${declaracion.año}-${String(declaracion.mes).padStart(2, '0')}`;
const periodicidad = row.periodicidad || 'mensual';
alertasResueltas += await completarObligacionesPorDeclaracion( const { rows: relaciones } = await pool.query<{ obligacion_id: string }>(
pool, row.contribuyente_id, declaracion.impuestos, periodo, data.uploadedByUserId, declaracion.id, periodicidad, `SELECT obligacion_id FROM declaracion_obligaciones WHERE declaracion_id = $1`,
[id],
); );
if (relaciones.length > 0) {
const obligaciones = await getObligacionesPorIds(
pool, row.contribuyente_id, relaciones.map(r => r.obligacion_id),
);
await registrarEvidenciasPorObligaciones(
pool, obligaciones, row.contribuyente_id, periodo, data.uploadedByUserId,
data.pdfBase64, data.pdfFilename, 'pago', declaracion.notas ?? undefined,
);
} else if (declaracion.impuestos.length > 0) {
const periodicidad = row.periodicidad || 'mensual';
await registrarEvidenciasPorDeclaracion(
pool, row.contribuyente_id, declaracion.impuestos, periodo, data.uploadedByUserId,
data.pdfBase64, data.pdfFilename, 'pago', periodicidad,
);
}
} }
return { declaracion, alertasResueltas }; return { declaracion, alertasResueltas };

View File

@@ -1,5 +1,6 @@
import type { Pool } from 'pg'; import type { Pool } from 'pg';
import { prisma } from '../config/database.js'; import { prisma } from '../config/database.js';
import { materializarPeriodos } from './tareas.service.js';
export interface ContribuyentesStats { export interface ContribuyentesStats {
totalContribuyentes: number; totalContribuyentes: number;
@@ -210,30 +211,62 @@ export async function getMisAsignados(
const inicioMes = `${_año}-${String(_mes).padStart(2, '0')}-01`; const inicioMes = `${_año}-${String(_mes).padStart(2, '0')}-01`;
const finMes = new Date(_año, _mes, 0).toISOString().split('T')[0]; const finMes = new Date(_año, _mes, 0).toISOString().split('T')[0];
// Materializar periodos de tareas antes de contar (evita que tareas sin
// registro en tarea_periodos aparezcan como 0).
await Promise.all(ids.map(id => materializarPeriodos(pool, id).catch(() => {})));
const { rows: stats } = await pool.query( const { rows: stats } = await pool.query(
`WITH obl AS ( `WITH obligaciones_activas AS (
SELECT oc.contribuyente_id, SELECT id, contribuyente_id FROM obligaciones_contribuyente
COUNT(*) FILTER (WHERE op.completada = false AND op.periodo = $1)::int AS pendientes, WHERE contribuyente_id = ANY($4::uuid[]) AND activa = true
COUNT(*) FILTER (WHERE op.completada = false AND op.periodo < $1)::int AS atrasadas, ),
COUNT(*) FILTER (WHERE op.completada = true AND op.periodo = $1)::int AS completadas op_actual AS (
FROM obligaciones_contribuyente oc SELECT obligacion_id, completada FROM obligacion_periodos
LEFT JOIN obligacion_periodos op ON op.obligacion_id = oc.id WHERE obligacion_id IN (SELECT id FROM obligaciones_activas) AND periodo = $1
WHERE oc.contribuyente_id = ANY($4::uuid[]) AND oc.activa = true ),
GROUP BY oc.contribuyente_id op_atrasadas AS (
SELECT obligacion_id, COUNT(*) as atrasadas FROM obligacion_periodos
WHERE obligacion_id IN (SELECT id FROM obligaciones_activas) AND periodo < $1 AND completada = false
GROUP BY obligacion_id
),
obl AS (
SELECT oa.contribuyente_id,
COUNT(*) FILTER (WHERE op_a.completada IS NULL OR op_a.completada = false)::int AS pendientes,
COALESCE(SUM(op_atr.atrasadas), 0)::int AS atrasadas,
COUNT(*) FILTER (WHERE op_a.completada = true)::int AS completadas
FROM obligaciones_activas oa
LEFT JOIN op_actual op_a ON op_a.obligacion_id = oa.id
LEFT JOIN op_atrasadas op_atr ON op_atr.obligacion_id = oa.id
GROUP BY oa.contribuyente_id
),
tareas_activas AS (
SELECT id, contribuyente_id FROM tareas_catalogo
WHERE contribuyente_id = ANY($4::uuid[]) AND active = true
),
tar_actual AS (
SELECT tarea_id, completada FROM tarea_periodos
WHERE tarea_id IN (SELECT id FROM tareas_activas)
AND fecha_limite BETWEEN $2::date AND $3::date
),
tar_atrasadas AS (
SELECT tarea_id, COUNT(*) as atrasadas FROM tarea_periodos
WHERE tarea_id IN (SELECT id FROM tareas_activas)
AND fecha_limite < $2::date AND completada = false
GROUP BY tarea_id
), ),
tar AS ( tar AS (
SELECT tc.contribuyente_id, SELECT ta.contribuyente_id,
COUNT(*) FILTER (WHERE tp.completada = false AND tp.fecha_limite BETWEEN $2::date AND $3::date)::int AS pendientes, COUNT(*) FILTER (WHERE tar_a.completada IS NULL OR tar_a.completada = false)::int AS pendientes,
COUNT(*) FILTER (WHERE tp.completada = false AND tp.fecha_limite < $2::date)::int AS atrasadas, COALESCE(SUM(tar_atr.atrasadas), 0)::int AS atrasadas,
COUNT(*) FILTER (WHERE tp.completada = true AND tp.fecha_limite BETWEEN $2::date AND $3::date)::int AS completadas COUNT(*) FILTER (WHERE tar_a.completada = true)::int AS completadas
FROM tareas_catalogo tc FROM tareas_activas ta
LEFT JOIN tarea_periodos tp ON tp.tarea_id = tc.id LEFT JOIN tar_actual tar_a ON tar_a.tarea_id = ta.id
WHERE tc.contribuyente_id = ANY($4::uuid[]) AND tc.active = true LEFT JOIN tar_atrasadas tar_atr ON tar_atr.tarea_id = ta.id
GROUP BY tc.contribuyente_id GROUP BY ta.contribuyente_id
) )
SELECT SELECT
obl.contribuyente_id AS obl_id, obl.pendientes AS obl_pen, obl.atrasadas AS obl_atr, obl.completadas AS obl_com, obl.contribuyente_id AS obl_id, obl.pendientes AS obl_pen, obl.atrasadas AS obl_atr, obl.completadas AS obl_com,
tar.contribuyente_id AS tar_id, tar.pendientes AS tar_pen, tar.atrasadas AS tar_atr, tar.completadas AS tar_com tar.contribuyente_id AS tar_id, tar.pendientes AS tar_pen, tar.atrasadas AS tar_atr, tar.completadas AS tar_com
FROM obl FROM obl
FULL OUTER JOIN tar ON tar.contribuyente_id = obl.contribuyente_id`, FULL OUTER JOIN tar ON tar.contribuyente_id = obl.contribuyente_id`,
[periodoMes, inicioMes, finMes, ids], [periodoMes, inicioMes, finMes, ids],

View File

@@ -1,4 +1,4 @@
import { createEmailTransport } from '@horux/core'; import { createEmailTransport, type EmailAttachment } from '@horux/core';
import { env } from '../../config/env.js'; import { env } from '../../config/env.js';
const transport = createEmailTransport( const transport = createEmailTransport(
@@ -13,12 +13,12 @@ const transport = createEmailTransport(
: null : null
); );
async function sendEmail(to: string, subject: string, html: string) { async function sendEmail(to: string, subject: string, html: string, attachments?: EmailAttachment[]) {
await transport.send(to, subject, html); await transport.send(to, subject, html, attachments);
} }
export const emailService = { export const emailService = {
sendWelcome: async (to: string, data: { nombre: string; email: string; tempPassword: string }) => { sendWelcome: async (to: string, data: { nombre: string; email: string; tempPassword: string | null | undefined }) => {
const { welcomeEmail } = await import('./templates/welcome.js'); const { welcomeEmail } = await import('./templates/welcome.js');
await sendEmail(to, 'Bienvenido a Horux360', welcomeEmail(data)); await sendEmail(to, 'Bienvenido a Horux360', welcomeEmail(data));
}, },
@@ -60,7 +60,7 @@ export const emailService = {
clienteRfc: string; clienteRfc: string;
adminEmail: string; adminEmail: string;
adminNombre: string; adminNombre: string;
tempPassword: string; tempPassword: string | null | undefined;
databaseName: string; databaseName: string;
plan: string; plan: string;
}) => { }) => {
@@ -128,10 +128,14 @@ export const emailService = {
* Notifica la subida de una declaración o documento extra al despacho. * Notifica la subida de una declaración o documento extra al despacho.
* `recipients` debe venir deduplicado por el caller. El subject se * `recipients` debe venir deduplicado por el caller. El subject se
* genera a partir del kind y RFC del contribuyente. * genera a partir del kind y RFC del contribuyente.
*
* Para declaraciones, `attachments` puede contener los PDFs subidos
* (acuse + liga de pago) para enviarlos adjuntos al correo.
*/ */
sendDocumentoSubido: async ( sendDocumentoSubido: async (
recipients: string[], recipients: string[],
data: import('./templates/documento-subido.js').DocumentoSubidoData, data: import('./templates/documento-subido.js').DocumentoSubidoData,
attachments?: EmailAttachment[],
) => { ) => {
if (recipients.length === 0) return; if (recipients.length === 0) return;
const { documentoSubidoEmail } = await import('./templates/documento-subido.js'); const { documentoSubidoEmail } = await import('./templates/documento-subido.js');
@@ -143,7 +147,7 @@ export const emailService = {
// destinatario NO debe impedir enviar al siguiente. // destinatario NO debe impedir enviar al siguiente.
for (const to of recipients) { for (const to of recipients) {
try { try {
await sendEmail(to, subject, html); await sendEmail(to, subject, html, attachments);
} catch (err: any) { } catch (err: any) {
console.error(`[Email] Fallo enviando documento-subido a ${to}:`, err?.message || err); console.error(`[Email] Fallo enviando documento-subido a ${to}:`, err?.message || err);
} }
@@ -193,6 +197,19 @@ export const emailService = {
); );
}, },
/** Clientes reciben aviso cuando se sube papelería que requiere su aprobación. */
sendPapeleriaAprobacionClienteRequerida: async (
to: string,
data: import('./templates/papeleria.js').PapeleriaAprobacionClienteRequeridaData,
) => {
const { papeleriaAprobacionClienteRequeridaEmail } = await import('./templates/papeleria.js');
await sendEmail(
to,
`📋 Documento pendiente de tu aprobación — ${data.contribuyenteRfc}`,
papeleriaAprobacionClienteRequeridaEmail(data),
);
},
/** /**
* Cron 8:30 AM — alertas fiscales nuevas activadas hoy. Envía un solo * Cron 8:30 AM — alertas fiscales nuevas activadas hoy. Envía un solo
* correo por destinatario con el batch completo. Caller debe deduplicar * correo por destinatario con el batch completo. Caller debe deduplicar

View File

@@ -2,7 +2,7 @@ import { baseTemplate, heading, infoBox, primaryButton, BRAND_COLORS as C } from
export interface DocumentoSubidoData { export interface DocumentoSubidoData {
/** Kind: para el título/subject. */ /** Kind: para el título/subject. */
kind: 'declaracion' | 'extra'; kind: 'declaracion' | 'extra' | 'obligacion_evidencia';
/** Quién subió el documento (email). */ /** Quién subió el documento (email). */
subidoPor: string; subidoPor: string;
/** RFC del contribuyente. */ /** RFC del contribuyente. */
@@ -24,25 +24,38 @@ export interface DocumentoSubidoData {
descripcion?: string | null; descripcion?: string | null;
categoria?: string | null; categoria?: string | null;
}; };
/** Si es evidencia de obligación fiscal. */
evidencia?: {
obligacionNombre: string;
periodo: string;
tipoDocumento: string;
filename: string;
};
/** URL al sistema (ej. https://despachos.horuxfin.com/documentos). */ /** URL al sistema (ej. https://despachos.horuxfin.com/documentos). */
link: string; link: string;
/** Solo para declaraciones: los adjuntos se omitieron por exceder el límite de tamaño. */
attachmentsOmitted?: boolean;
} }
export function documentoSubidoEmail(data: DocumentoSubidoData): string { export function documentoSubidoEmail(data: DocumentoSubidoData): string {
const titulo = data.kind === 'declaracion' const titulo = data.kind === 'declaracion'
? 'Nueva declaración subida' ? 'Nueva declaración subida'
: 'Nuevo documento subido'; : data.kind === 'obligacion_evidencia'
? 'Nueva evidencia de obligación fiscal'
: 'Nuevo documento subido';
const contenidoEspecifico = data.kind === 'declaracion' && data.declaracion const contenidoEspecifico = data.kind === 'declaracion' && data.declaracion
? declaracionBlock(data.declaracion) ? declaracionBlock(data.declaracion)
: data.extra : data.kind === 'obligacion_evidencia' && data.evidencia
? extraBlock(data.extra) ? evidenciaBlock(data.evidencia)
: ''; : data.extra
? extraBlock(data.extra)
: '';
return baseTemplate(` return baseTemplate(`
${heading(titulo)} ${heading(titulo)}
<p style="color:${C.textPrimary};margin:0 0 16px;"> <p style="color:${C.textPrimary};margin:0 0 16px;">
<strong>${escapeHtml(data.subidoPor)}</strong> subió un ${data.kind === 'declaracion' ? 'acuse de declaración' : 'documento'} <strong>${escapeHtml(data.subidoPor)}</strong> subió ${data.kind === 'obligacion_evidencia' ? 'una evidencia de obligación fiscal' : data.kind === 'declaracion' ? 'un acuse de declaración' : 'un documento'}
para <strong>${escapeHtml(data.contribuyenteNombre)}</strong>. para <strong>${escapeHtml(data.contribuyenteNombre)}</strong>.
</p> </p>
${infoBox(` ${infoBox(`
@@ -57,6 +70,12 @@ export function documentoSubidoEmail(data: DocumentoSubidoData): string {
<div style="margin-top:24px;"> <div style="margin-top:24px;">
${primaryButton('Ver en el sistema', data.link)} ${primaryButton('Ver en el sistema', data.link)}
</div> </div>
${data.kind === 'declaracion' && data.attachmentsOmitted ? `
<p style="color:${C.textMuted};font-size:13px;margin-top:16px;">
Los documentos no se adjuntaron porque exceden el tamaño permitido por correo.
Puedes descargarlos desde el sistema.
</p>
` : ''}
`); `);
} }
@@ -76,6 +95,19 @@ function declaracionBlock(d: NonNullable<DocumentoSubidoData['declaracion']>): s
`; `;
} }
function evidenciaBlock(e: NonNullable<DocumentoSubidoData['evidencia']>): string {
return `
<p style="margin:0 0 6px;color:${C.textMuted};font-size:13px;">Obligación</p>
<p style="margin:0 0 12px;color:${C.textPrimary};font-weight:600;">${escapeHtml(e.obligacionNombre)}</p>
<p style="margin:0 0 6px;color:${C.textMuted};font-size:13px;">Periodo</p>
<p style="margin:0 0 12px;color:${C.textPrimary};">${escapeHtml(e.periodo)}</p>
<p style="margin:0 0 6px;color:${C.textMuted};font-size:13px;">Tipo de documento</p>
<p style="margin:0 0 12px;color:${C.textPrimary};text-transform:capitalize;">${escapeHtml(e.tipoDocumento)}</p>
<p style="margin:0 0 6px;color:${C.textMuted};font-size:13px;">Archivo</p>
<p style="margin:0 0 12px;color:${C.textPrimary};">${escapeHtml(e.filename)}</p>
`;
}
function extraBlock(e: NonNullable<DocumentoSubidoData['extra']>): string { function extraBlock(e: NonNullable<DocumentoSubidoData['extra']>): string {
return ` return `
<p style="margin:0 0 6px;color:${C.textMuted};font-size:13px;">Documento</p> <p style="margin:0 0 6px;color:${C.textMuted};font-size:13px;">Documento</p>

View File

@@ -25,7 +25,7 @@ export function newClientAdminEmail(data: {
clienteRfc: string; clienteRfc: string;
adminEmail: string; adminEmail: string;
adminNombre: string; adminNombre: string;
tempPassword: string; tempPassword: string | null | undefined;
databaseName: string; databaseName: string;
plan: string; plan: string;
}): string { }): string {
@@ -46,7 +46,7 @@ export function newClientAdminEmail(data: {
${sectionHeader('Credenciales del usuario', C.secondary)} ${sectionHeader('Credenciales del usuario', C.secondary)}
${row('Nombre', escapeHtml(data.adminNombre))} ${row('Nombre', escapeHtml(data.adminNombre))}
${row('Email', `<span style="font-family:monospace;">${escapeHtml(data.adminEmail)}</span>`)} ${row('Email', `<span style="font-family:monospace;">${escapeHtml(data.adminEmail)}</span>`)}
${row('Contraseña temporal', `<code style="background-color:${C.bgLight};padding:4px 10px;border-radius:6px;font-size:13px;color:#dc2626;border:1px solid ${C.border};">${escapeHtml(data.tempPassword)}</code>`, true)} ${row('Contraseña temporal', `<code style="background-color:${C.bgLight};padding:4px 10px;border-radius:6px;font-size:13px;color:#dc2626;border:1px solid ${C.border};">${escapeHtml(data.tempPassword || 'N/A - usuario ya existía')}</code>`, true)}
</table> </table>
<div style="background-color:#fef2f2;border-left:4px solid #ef4444;border-radius:8px;padding:12px 16px;margin:0 0 16px;"> <div style="background-color:#fef2f2;border-left:4px solid #ef4444;border-radius:8px;padding:12px 16px;margin:0 0 16px;">

View File

@@ -55,3 +55,32 @@ export function papeleriaDecisionEmail(d: PapeleriaDecisionData): string {
`; `;
return baseTemplate(body); return baseTemplate(body);
} }
export interface PapeleriaAprobacionClienteRequeridaData {
contribuyenteRfc: string;
contribuyenteNombre: string;
despachoNombre?: string;
nombreDocumento: string;
descripcion: string | null;
periodo: string;
subidoPor: string;
link: string;
}
export function papeleriaAprobacionClienteRequeridaEmail(d: PapeleriaAprobacionClienteRequeridaData): string {
const body = `
${heading('Documento pendiente de tu aprobación')}
<p>${d.subidoPor} subió un documento que requiere tu aprobación como cliente:</p>
<ul>
<li><strong>Documento:</strong> ${d.nombreDocumento}</li>
<li><strong>Contribuyente:</strong> ${d.contribuyenteNombre} (${d.contribuyenteRfc})</li>
<li><strong>Periodo:</strong> ${d.periodo}</li>
${d.descripcion ? `<li><strong>Descripción:</strong> ${d.descripcion}</li>` : ''}
</ul>
${infoBox('Revisa el documento y márcalo como aprobado o rechazado desde la sección de Documentos del despacho.')}
<div style="margin-top: 24px;">
${primaryButton('Ver documento', d.link)}
</div>
`;
return baseTemplate(body);
}

View File

@@ -1,6 +1,6 @@
import { baseTemplate, heading, primaryButton, infoBox, BRAND_COLORS as C } from './base.js'; import { baseTemplate, heading, primaryButton, infoBox, BRAND_COLORS as C } from './base.js';
export function welcomeEmail(data: { nombre: string; email: string; tempPassword: string }): string { export function welcomeEmail(data: { nombre: string; email: string; tempPassword: string | null | undefined }): string {
return baseTemplate(` return baseTemplate(`
${heading('Bienvenido a Horux 360')} ${heading('Bienvenido a Horux 360')}
<p style="color:${C.textPrimary};margin:0 0 16px;">Hola <strong>${data.nombre}</strong>,</p> <p style="color:${C.textPrimary};margin:0 0 16px;">Hola <strong>${data.nombre}</strong>,</p>

View File

@@ -18,11 +18,11 @@ export async function exportCfdisToExcel(
params.push(filters.estado); params.push(filters.estado);
} }
if (filters.fechaInicio) { if (filters.fechaInicio) {
whereClause += ` AND fecha_emision >= $${paramIndex++}`; whereClause += ` AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') >= $${paramIndex++}`;
params.push(filters.fechaInicio); params.push(filters.fechaInicio);
} }
if (filters.fechaFin) { if (filters.fechaFin) {
whereClause += ` AND fecha_emision <= $${paramIndex++}`; whereClause += ` AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') <= $${paramIndex++}`;
params.push(filters.fechaFin); params.push(filters.fechaFin);
} }
@@ -74,7 +74,7 @@ export async function exportCfdisToExcel(
cfdis.forEach((cfdi: any) => { cfdis.forEach((cfdi: any) => {
sheet.addRow({ sheet.addRow({
...cfdi, ...cfdi,
fecha_emision: new Date(cfdi.fecha_emision).toLocaleDateString('es-MX'), fecha_emision: new Date(new Date(cfdi.fecha_emision).getTime() - 60*60*1000).toLocaleDateString('es-MX'),
subtotal: Number(cfdi.subtotal), subtotal: Number(cfdi.subtotal),
subtotal_mxn: Number(cfdi.subtotal_mxn), subtotal_mxn: Number(cfdi.subtotal_mxn),
iva_traslado: Number(cfdi.iva_traslado), iva_traslado: Number(cfdi.iva_traslado),
@@ -103,7 +103,7 @@ export async function exportReporteToExcel(
COALESCE(SUM(CASE WHEN type = 'EMITIDO' AND tipo_comprobante = 'I' THEN subtotal_mxn ELSE 0 END), 0) as ingresos, COALESCE(SUM(CASE WHEN type = 'EMITIDO' AND tipo_comprobante = 'I' THEN subtotal_mxn ELSE 0 END), 0) as ingresos,
COALESCE(SUM(CASE WHEN type = 'RECIBIDO' AND tipo_comprobante = 'I' THEN subtotal_mxn ELSE 0 END), 0) as egresos COALESCE(SUM(CASE WHEN type = 'RECIBIDO' AND tipo_comprobante = 'I' THEN subtotal_mxn ELSE 0 END), 0) as egresos
FROM cfdis FROM cfdis
WHERE status NOT IN ('Cancelado', '0') AND fecha_emision BETWEEN $1 AND $2 WHERE status NOT IN ('Cancelado', '0') AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') BETWEEN $1 AND $2
`, [fechaInicio, fechaFin]); `, [fechaInicio, fechaFin]);
sheet.columns = [ sheet.columns = [

View File

@@ -315,7 +315,7 @@ export async function createInvoice(
unit_key: item.unitKey || 'E48', unit_key: item.unitKey || 'E48',
unit_name: item.unitName || 'Servicio', unit_name: item.unitName || 'Servicio',
price: item.price, price: item.price,
tax_included: item.taxIncluded ?? true, tax_included: item.taxIncluded ?? false,
taxes: item.taxes?.map(t => ({ taxes: item.taxes?.map(t => ({
type: t.type, type: t.type,
rate: t.rate, rate: t.rate,
@@ -323,6 +323,7 @@ export async function createInvoice(
...(t.withholding ? { withholding: true } : {}), ...(t.withholding ? { withholding: true } : {}),
})) || [{ type: 'IVA', rate: 0.16 }], })) || [{ type: 'IVA', rate: 0.16 }],
}, },
...((data as any).cuentaPredial ? { property_tax_account: (data as any).cuentaPredial } : {}),
})); }));
} }
@@ -340,6 +341,7 @@ export async function createInvoice(
if (data.series) invoiceData.series = data.series; if (data.series) invoiceData.series = data.series;
if (data.folioNumber) invoiceData.folio_number = data.folioNumber; if (data.folioNumber) invoiceData.folio_number = data.folioNumber;
if ((data as any).fechaEmision) invoiceData.date = (data as any).fechaEmision;
// Documentos relacionados (Ingreso / Egreso / Pago / Traslado). // Documentos relacionados (Ingreso / Egreso / Pago / Traslado).
if (data.relatedDocuments?.length) { if (data.relatedDocuments?.length) {

View File

@@ -1,4 +1,4 @@
import type { Pool } from 'pg'; import type { Pool, PoolClient } from 'pg';
import type { IvaMensual, IsrMensual, ResumenIva, IvaRegimenDetalle, ResumenIsr } from '@horux/shared'; import type { IvaMensual, IsrMensual, ResumenIva, IvaRegimenDetalle, ResumenIsr } from '@horux/shared';
import { getRegimenesIgnoradosClaves } from './regimen.service.js'; import { getRegimenesIgnoradosClaves } from './regimen.service.js';
import { import {
@@ -22,7 +22,7 @@ const VIGENTE = `status NOT IN ('Cancelado', '0')`;
// - otros tipos (I, E, T, N): fecha_emision del comprobante // - otros tipos (I, E, T, N): fecha_emision del comprobante
// El CASE se evalúa por fila, garantizando que un P emitido en mayo por un pago // El CASE se evalúa por fila, garantizando que un P emitido en mayo por un pago
// real de noviembre quede contabilizado en noviembre. // real de noviembre quede contabilizado en noviembre.
const FECHA_EFECTIVA = `CASE WHEN tipo_comprobante = 'P' THEN fecha_pago_p ELSE fecha_emision END`; const FECHA_EFECTIVA = `CASE WHEN tipo_comprobante = 'P' THEN (fecha_pago_p - interval '1 hour') ELSE COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') END`;
const FECHA_RANGO = `${FECHA_EFECTIVA} >= $1::date AND ${FECHA_EFECTIVA} < ($2::date + interval '1 day')`; const FECHA_RANGO = `${FECHA_EFECTIVA} >= $1::date AND ${FECHA_EFECTIVA} < ($2::date + interval '1 day')`;
const FECHA_RANGO_CONCILIACION = `id_conciliacion IS NOT NULL AND id_conciliacion IN ( const FECHA_RANGO_CONCILIACION = `id_conciliacion IS NOT NULL AND id_conciliacion IN (
SELECT id FROM conciliaciones WHERE fecha_de_pago >= $1::date AND fecha_de_pago < ($2::date + interval '1 day') SELECT id FROM conciliaciones WHERE fecha_de_pago >= $1::date AND fecha_de_pago < ($2::date + interval '1 day')
@@ -106,32 +106,40 @@ const SUM_E_REFERENCING_TRAS = (
esLadoE: string, esLadoE: string,
considerarActivos: boolean, considerarActivos: boolean,
considerarNCs: boolean, considerarNCs: boolean,
) => `COALESCE(( ) => {
SELECT SUM(${IVA_TRAS_EXPR_ALIAS('e')}) if (!considerarNCs) return '0';
FROM cfdis e return `COALESCE((
WHERE e.tipo_comprobante = 'E' SELECT SUM(${IVA_TRAS_EXPR_ALIAS('e')})
AND e.metodo_pago = 'PUE' FROM cfdis e
AND e.status NOT IN ('Cancelado', '0') WHERE e.tipo_comprobante = 'E'
AND ${esLadoE} AND e.metodo_pago = 'PUE'
AND LOWER(cfdis.uuid) = ANY(string_to_array(LOWER(e.cfdis_relacionados), '|')) AND e.status NOT IN ('Cancelado', '0')
AND date_trunc('month', e.fecha_emision) AND ${esLadoE}
= date_trunc('month', cfdis.fecha_emision)${buildExtraFiltersAlias('e', considerarActivos, considerarNCs)} AND e.cfdis_relacionados IS NOT NULL
), 0)`; AND string_to_array(LOWER(e.cfdis_relacionados), '|') @> ARRAY[LOWER(cfdis.uuid)]
AND date_trunc('month', COALESCE(e.fecha_efectiva, e.fecha_emision - interval '1 hour'))
= date_trunc('month', COALESCE(cfdis.fecha_efectiva, cfdis.fecha_emision - interval '1 hour'))${buildExtraFiltersAlias('e', considerarActivos, considerarNCs)}
), 0)`;
};
const SUM_E_REFERENCING_RET = ( const SUM_E_REFERENCING_RET = (
esLadoE: string, esLadoE: string,
considerarActivos: boolean, considerarActivos: boolean,
considerarNCs: boolean, considerarNCs: boolean,
) => `COALESCE(( ) => {
SELECT SUM(${IVA_RET_EXPR_ALIAS('e')}) if (!considerarNCs) return '0';
FROM cfdis e return `COALESCE((
WHERE e.tipo_comprobante = 'E' SELECT SUM(${IVA_RET_EXPR_ALIAS('e')})
AND e.metodo_pago = 'PUE' FROM cfdis e
AND e.status NOT IN ('Cancelado', '0') WHERE e.tipo_comprobante = 'E'
AND ${esLadoE} AND e.metodo_pago = 'PUE'
AND LOWER(cfdis.uuid) = ANY(string_to_array(LOWER(e.cfdis_relacionados), '|')) AND e.status NOT IN ('Cancelado', '0')
AND date_trunc('month', e.fecha_emision) AND ${esLadoE}
= date_trunc('month', cfdis.fecha_emision)${buildExtraFiltersAlias('e', considerarActivos, considerarNCs)} AND e.cfdis_relacionados IS NOT NULL
), 0)`; AND string_to_array(LOWER(e.cfdis_relacionados), '|') @> ARRAY[LOWER(cfdis.uuid)]
AND date_trunc('month', COALESCE(e.fecha_efectiva, e.fecha_emision - interval '1 hour'))
= date_trunc('month', COALESCE(cfdis.fecha_efectiva, cfdis.fecha_emision - interval '1 hour'))${buildExtraFiltersAlias('e', considerarActivos, considerarNCs)}
), 0)`;
};
// Régimen del contribuyente según su lado: emisor/receptor del CFDI. // Régimen del contribuyente según su lado: emisor/receptor del CFDI.
// Usa el RFC del contribuyente (via `ctx.esEmisor`/`ctx.esReceptor`) para // Usa el RFC del contribuyente (via `ctx.esEmisor`/`ctx.esReceptor`) para
// determinar el lado, no el `type` de BD. // determinar el lado, no el `type` de BD.
@@ -152,16 +160,20 @@ const HAS_E_REFERENCING_MISMO_MES = (
esLadoE: string, esLadoE: string,
considerarActivos: boolean, considerarActivos: boolean,
considerarNCs: boolean, considerarNCs: boolean,
) => `EXISTS ( ) => {
SELECT 1 FROM cfdis e if (!considerarNCs) return 'FALSE';
WHERE e.tipo_comprobante = 'E' return `EXISTS (
AND e.metodo_pago = 'PUE' SELECT 1 FROM cfdis e
AND e.status NOT IN ('Cancelado', '0') WHERE e.tipo_comprobante = 'E'
AND ${esLadoE} AND e.metodo_pago = 'PUE'
AND LOWER(cfdis.uuid) = ANY(string_to_array(LOWER(e.cfdis_relacionados), '|')) AND e.status NOT IN ('Cancelado', '0')
AND date_trunc('month', e.fecha_emision) AND ${esLadoE}
= date_trunc('month', cfdis.fecha_emision)${buildExtraFiltersAlias('e', considerarActivos, considerarNCs)} AND e.cfdis_relacionados IS NOT NULL
)`; AND string_to_array(LOWER(e.cfdis_relacionados), '|') @> ARRAY[LOWER(cfdis.uuid)]
AND date_trunc('month', e.fecha_emision)
= date_trunc('month', cfdis.fecha_emision)${buildExtraFiltersAlias('e', considerarActivos, considerarNCs)}
)`;
};
// Atribución por lado usando RFC en lugar de `type`. Los buckets son // Atribución por lado usando RFC en lugar de `type`. Los buckets son
// factories que reciben el context del contribuyente: // factories que reciben el context del contribuyente:
@@ -397,8 +409,8 @@ export async function getIvaMensual(
const añoEnd = `${año}-12-31`; const añoEnd = `${año}-12-31`;
const extra = buildExtraFilters(considerarActivos, considerarNCs); const extra = buildExtraFilters(considerarActivos, considerarNCs);
const [{ rows: causadoRows }, { rows: acreditableRows }] = await Promise.all([ const { rows: causadoRows } = await withJitOff(pool, (client) =>
pool.query<{ mes: number; trasladado: string; retencion: string }>(` client.query<{ mes: number; trasladado: string; retencion: string }>(`
SELECT EXTRACT(MONTH FROM ${FECHA_EFECTIVA})::int as mes, SELECT EXTRACT(MONTH FROM ${FECHA_EFECTIVA})::int as mes,
COALESCE(SUM(${signedCausadoTras(ctx, considerarActivos, considerarNCs)}), 0) as trasladado, COALESCE(SUM(${signedCausadoTras(ctx, considerarActivos, considerarNCs)}), 0) as trasladado,
COALESCE(SUM(${signedCausadoRet(ctx, considerarActivos, considerarNCs)}), 0) as retencion COALESCE(SUM(${signedCausadoRet(ctx, considerarActivos, considerarNCs)}), 0) as retencion
@@ -407,8 +419,10 @@ export async function getIvaMensual(
AND ${VIGENTE} AND ${FR}${extra} AND ${VIGENTE} AND ${FR}${extra}
AND (${REGIMEN_TENANT}) = ANY($3) AND (${REGIMEN_TENANT}) = ANY($3)
GROUP BY mes GROUP BY mes
`, [añoStart, añoEnd, TODOS_REGIMENES]), `, [añoStart, añoEnd, TODOS_REGIMENES])
pool.query<{ mes: number; trasladado: string; retencion: string }>(` );
const { rows: acreditableRows } = await withJitOff(pool, (client) =>
client.query<{ mes: number; trasladado: string; retencion: string }>(`
SELECT EXTRACT(MONTH FROM ${FECHA_EFECTIVA})::int as mes, SELECT EXTRACT(MONTH FROM ${FECHA_EFECTIVA})::int as mes,
COALESCE(SUM(${signedAcreditableTras(ctx, considerarActivos, considerarNCs)}), 0) as trasladado, COALESCE(SUM(${signedAcreditableTras(ctx, considerarActivos, considerarNCs)}), 0) as trasladado,
COALESCE(SUM(${signedAcreditableRet(ctx, considerarActivos, considerarNCs)}), 0) as retencion COALESCE(SUM(${signedAcreditableRet(ctx, considerarActivos, considerarNCs)}), 0) as retencion
@@ -417,8 +431,8 @@ export async function getIvaMensual(
AND ${VIGENTE} AND ${FR}${extra} AND ${VIGENTE} AND ${FR}${extra}
AND (${REGIMEN_TENANT}) = ANY($3) AND (${REGIMEN_TENANT}) = ANY($3)
GROUP BY mes GROUP BY mes
`, [añoStart, añoEnd, TODOS_REGIMENES]), `, [añoStart, añoEnd, TODOS_REGIMENES])
]); );
perMes = new Map(); perMes = new Map();
for (const row of causadoRows) { for (const row of causadoRows) {
@@ -648,20 +662,22 @@ async function readResumenIvaFromCache(
const añoInicio = new Date(fechaInicio + 'T00:00:00').getFullYear(); const añoInicio = new Date(fechaInicio + 'T00:00:00').getFullYear();
const acumFR = conciliacion ? FECHA_RANGO_CONCILIACION : FECHA_RANGO; const acumFR = conciliacion ? FECHA_RANGO_CONCILIACION : FECHA_RANGO;
const REGIMEN_TENANT = regimenTenantExpr(ctx); const REGIMEN_TENANT = regimenTenantExpr(ctx);
const acumRow = (await pool.query(` const acumRow = (await withJitOff(pool, (client) =>
SELECT client.query(`
COALESCE(SUM(${signedCausadoTras(ctx, considerarActivos, considerarNCs)}), 0) - SELECT
COALESCE(SUM(${signedAcreditableTras(ctx, considerarActivos, considerarNCs)}), 0) - COALESCE(SUM(${signedCausadoTras(ctx, considerarActivos, considerarNCs)}), 0) -
( COALESCE(SUM(${signedAcreditableTras(ctx, considerarActivos, considerarNCs)}), 0) -
COALESCE(SUM(${signedCausadoRet(ctx, considerarActivos, considerarNCs)}), 0) - (
COALESCE(SUM(${signedAcreditableRet(ctx, considerarActivos, considerarNCs)}), 0) COALESCE(SUM(${signedCausadoRet(ctx, considerarActivos, considerarNCs)}), 0) -
) as total COALESCE(SUM(${signedAcreditableRet(ctx, considerarActivos, considerarNCs)}), 0)
FROM cfdis ) as total
WHERE ${VIGENTE} FROM cfdis
AND (${REGIMEN_TENANT}) = ANY($3) WHERE ${VIGENTE}
AND ${acumFR} AND (${REGIMEN_TENANT}) = ANY($3)
AND (${ctx.esEmisor} OR ${ctx.esReceptor}) AND ${acumFR}
`, [`${añoInicio}-01-01`, fechaFin, TODOS_REGIMENES])).rows[0]; AND (${ctx.esEmisor} OR ${ctx.esReceptor})
`, [`${añoInicio}-01-01`, fechaFin, TODOS_REGIMENES])
)).rows[0];
// Cache hit retorna 0/empty para los surface IVA No Acreditable. El cache // Cache hit retorna 0/empty para los surface IVA No Acreditable. El cache
// aún no persiste esos campos — si se hace crítico para BI, agregar columna // aún no persiste esos campos — si se hace crítico para BI, agregar columna
@@ -698,6 +714,29 @@ async function readResumenIvaFromCache(
* *
* Algebraicamente: T A R == dashboard.balance, céntimo por céntimo. * Algebraicamente: T A R == dashboard.balance, céntimo por céntimo.
*/ */
/**
* Ejecuta un callback con un client de pool con JIT desactivado (SET LOCAL jit = off).
* Usa una transacción implícita para que el SET LOCAL se restaure automáticamente
* al liberar la conexión. Esto evita que PostgreSQL compile JIT para queries con
* muchos subplans (correlacionados), lo cual puede tardar >15s en queries con
* costo estimado muy alto aunque la ejecución real sea rápida.
*/
async function withJitOff<T>(pool: Pool, fn: (client: PoolClient) => Promise<T>): Promise<T> {
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query('SET LOCAL jit = off');
const result = await fn(client);
await client.query('COMMIT');
return result;
} catch (e) {
await client.query('ROLLBACK').catch(() => {});
throw e;
} finally {
client.release();
}
}
export async function getResumenIva( export async function getResumenIva(
pool: Pool, pool: Pool,
fechaInicio: string, fechaInicio: string,
@@ -725,10 +764,10 @@ export async function getResumenIva(
if (cached) return cached; if (cached) return cached;
} }
// Una query por lado (causado / acreditable). Filtro por RFC via // Queries con JIT off: evitan compilación JIT de >15s en queries con muchos
// ctx.esEmisor/esReceptor (embedded en buckets/signed exprs). // subplans correlacionados (activado por costo estimado >100k).
const [{ rows: causadoRows }, { rows: acreditableRows }] = await Promise.all([ const { rows: causadoRows } = await withJitOff(pool, (client) =>
pool.query<{ regimen: string | null; trasladado: string; retencion: string }>(` client.query<{ regimen: string | null; trasladado: string; retencion: string }>(`
SELECT ${REGIMEN_TENANT} as regimen, SELECT ${REGIMEN_TENANT} as regimen,
COALESCE(SUM(${signedCausadoTras(ctx, considerarActivos, considerarNCs)}), 0) as trasladado, COALESCE(SUM(${signedCausadoTras(ctx, considerarActivos, considerarNCs)}), 0) as trasladado,
COALESCE(SUM(${signedCausadoRet(ctx, considerarActivos, considerarNCs)}), 0) as retencion COALESCE(SUM(${signedCausadoRet(ctx, considerarActivos, considerarNCs)}), 0) as retencion
@@ -737,8 +776,10 @@ export async function getResumenIva(
AND ${VIGENTE} AND ${FR}${extra} AND ${VIGENTE} AND ${FR}${extra}
AND (${REGIMEN_TENANT}) = ANY($3) AND (${REGIMEN_TENANT}) = ANY($3)
GROUP BY ${REGIMEN_TENANT} GROUP BY ${REGIMEN_TENANT}
`, [fechaInicio, fechaFin, TODOS_REGIMENES]), `, [fechaInicio, fechaFin, TODOS_REGIMENES])
pool.query<{ regimen: string | null; trasladado: string; retencion: string }>(` );
const { rows: acreditableRows } = await withJitOff(pool, (client) =>
client.query<{ regimen: string | null; trasladado: string; retencion: string }>(`
SELECT ${REGIMEN_TENANT} as regimen, SELECT ${REGIMEN_TENANT} as regimen,
COALESCE(SUM(${signedAcreditableTras(ctx, considerarActivos, considerarNCs)}), 0) as trasladado, COALESCE(SUM(${signedAcreditableTras(ctx, considerarActivos, considerarNCs)}), 0) as trasladado,
COALESCE(SUM(${signedAcreditableRet(ctx, considerarActivos, considerarNCs)}), 0) as retencion COALESCE(SUM(${signedAcreditableRet(ctx, considerarActivos, considerarNCs)}), 0) as retencion
@@ -747,8 +788,8 @@ export async function getResumenIva(
AND ${VIGENTE} AND ${FR}${extra} AND ${VIGENTE} AND ${FR}${extra}
AND (${REGIMEN_TENANT}) = ANY($3) AND (${REGIMEN_TENANT}) = ANY($3)
GROUP BY ${REGIMEN_TENANT} GROUP BY ${REGIMEN_TENANT}
`, [fechaInicio, fechaFin, TODOS_REGIMENES]), `, [fechaInicio, fechaFin, TODOS_REGIMENES])
]); );
// Combinar por régimen: el set de régimenes posibles es la unión de ambos lados. // Combinar por régimen: el set de régimenes posibles es la unión de ambos lados.
type Acc = { trasCausado: number; retCausado: number; trasAcreditable: number; retAcreditable: number }; type Acc = { trasCausado: number; retCausado: number; trasAcreditable: number; retAcreditable: number };
@@ -799,20 +840,22 @@ export async function getResumenIva(
// Acumulado anual (misma fórmula T A R, pero rango = enero → fechaFin). // Acumulado anual (misma fórmula T A R, pero rango = enero → fechaFin).
const añoInicio = new Date(fechaInicio + 'T00:00:00').getFullYear(); const añoInicio = new Date(fechaInicio + 'T00:00:00').getFullYear();
const acumFR = conciliacion ? FECHA_RANGO_CONCILIACION : FECHA_RANGO; const acumFR = conciliacion ? FECHA_RANGO_CONCILIACION : FECHA_RANGO;
const { rows: [acumRow] } = await pool.query(` const { rows: [acumRow] } = await withJitOff(pool, (client) =>
SELECT client.query(`
COALESCE(SUM(${signedCausadoTras(ctx, considerarActivos, considerarNCs)}), 0) - SELECT
COALESCE(SUM(${signedAcreditableTras(ctx, considerarActivos, considerarNCs)}), 0) - COALESCE(SUM(${signedCausadoTras(ctx, considerarActivos, considerarNCs)}), 0) -
( COALESCE(SUM(${signedAcreditableTras(ctx, considerarActivos, considerarNCs)}), 0) -
COALESCE(SUM(${signedCausadoRet(ctx, considerarActivos, considerarNCs)}), 0) - (
COALESCE(SUM(${signedAcreditableRet(ctx, considerarActivos, considerarNCs)}), 0) COALESCE(SUM(${signedCausadoRet(ctx, considerarActivos, considerarNCs)}), 0) -
) as total COALESCE(SUM(${signedAcreditableRet(ctx, considerarActivos, considerarNCs)}), 0)
FROM cfdis ) as total
WHERE ${VIGENTE} FROM cfdis
AND (${REGIMEN_TENANT}) = ANY($3) WHERE ${VIGENTE}
AND ${acumFR}${extra} AND (${REGIMEN_TENANT}) = ANY($3)
AND (${ctx.esEmisor} OR ${ctx.esReceptor}) AND ${acumFR}${extra}
`, [`${añoInicio}-01-01`, fechaFin, TODOS_REGIMENES]); AND (${ctx.esEmisor} OR ${ctx.esReceptor})
`, [`${añoInicio}-01-01`, fechaFin, TODOS_REGIMENES])
);
// IVA No Acreditable surface (Art. 5 LIVA fracción I + Art. 27 fracción III LISR). // IVA No Acreditable surface (Art. 5 LIVA fracción I + Art. 27 fracción III LISR).
// No participa en `resultado` — ya excluido del `acreditable` arriba via filtro // No participa en `resultado` — ya excluido del `acreditable` arriba via filtro

View File

@@ -92,8 +92,8 @@ export async function computeMetricaMensual(
COUNT(*) FILTER (WHERE status = 'Vigente') AS vigentes, COUNT(*) FILTER (WHERE status = 'Vigente') AS vigentes,
COUNT(*) FILTER (WHERE status IN ('Cancelado','0')) AS cancelados COUNT(*) FILTER (WHERE status IN ('Cancelado','0')) AS cancelados
FROM cfdis FROM cfdis
WHERE EXTRACT(YEAR FROM (CASE WHEN tipo_comprobante='P' THEN fecha_pago_p ELSE fecha_emision END)) = $1 WHERE EXTRACT(YEAR FROM (CASE WHEN tipo_comprobante='P' THEN (fecha_pago_p - interval '1 hour') ELSE COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') END)) = $1
AND EXTRACT(MONTH FROM (CASE WHEN tipo_comprobante='P' THEN fecha_pago_p ELSE fecha_emision END)) = $2 AND EXTRACT(MONTH FROM (CASE WHEN tipo_comprobante='P' THEN (fecha_pago_p - interval '1 hour') ELSE COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') END)) = $2
AND contribuyente_id = $3 AND contribuyente_id = $3
GROUP BY 1, 2 GROUP BY 1, 2
`, [anio, mes, safeContrib]); `, [anio, mes, safeContrib]);
@@ -227,7 +227,7 @@ export async function backfillTenant(
for (const c of contribs) { for (const c of contribs) {
const { rows: [rango] } = await pool.query<{ min_anio: number | null }>( const { rows: [rango] } = await pool.query<{ min_anio: number | null }>(
`SELECT EXTRACT(YEAR FROM MIN(fecha_emision))::int AS min_anio `SELECT EXTRACT(YEAR FROM MIN(fecha_emision - interval '1 hour'))::int AS min_anio
FROM cfdis WHERE contribuyente_id = $1`, FROM cfdis WHERE contribuyente_id = $1`,
[c.entidad_id], [c.entidad_id],
); );

View File

@@ -1,30 +1,49 @@
import type { Pool } from 'pg'; import type { Pool } from 'pg';
/** /**
* Tipos de correos informativos cuyo envío puede desactivarse por * Tipos de correos informativos cuyo envío puede desactivarse por rol.
* contribuyente. NO incluye correos transaccionales críticos * NO incluye correos transaccionales críticos (welcome, password-reset,
* (welcome, password-reset, payment-*) — esos siempre se envían. * payment-*, invitaciones) — esos siempre se envían.
* *
* Estado de implementación: * Estado de implementación:
* - documento_subido: ✅ implementado (notify-upload.service.ts) * - documento_subido: ✅ implementado (owner + supervisor del contribuyente)
* - weekly_update: ⏳ pendiente (job es tenant-wide hoy) * - weekly_update: ✅ implementado (job tenant-wide, owners)
* - subscription_expiring: ⏳ pendiente (no es per-contribuyente hoy) * - subscription_expiring: ✅ implementado (aviso a owner)
* - recordatorio_fiscal: ⏳ placeholder para futuras alertas * - recordatorio_fiscal: ⏳ placeholder para futuras alertas
* - alertas_nuevas: ✅ implementado (supervisor + auxiliares + clientes)
* - recordatorio_proximo: ✅ implementado (auxiliar/supervisor/cliente/owner)
*/ */
export const EMAIL_TYPES = [ export const EMAIL_TYPES = [
'documento_subido', 'documento_subido',
'weekly_update', 'weekly_update',
'subscription_expiring', 'subscription_expiring',
'recordatorio_fiscal', 'recordatorio_fiscal',
'alertas_nuevas',
'recordatorio_proximo',
] as const; ] as const;
export type EmailType = (typeof EMAIL_TYPES)[number]; export type EmailType = (typeof EMAIL_TYPES)[number];
/**
* Roles que pueden recibir notificaciones informativas. Se excluyen roles
* que hoy no son destinatarios de ninguna notificación (cfo, contador, visor).
*/
export const NOTIFICATION_ROLES = [
'owner',
'supervisor',
'auxiliar',
'cliente',
] as const;
export type NotificationRole = (typeof NOTIFICATION_ROLES)[number];
export type EmailPreferences = Record<EmailType, boolean>; export type EmailPreferences = Record<EmailType, boolean>;
export type RoleEmailPreferences = Record<EmailType, Record<NotificationRole, boolean>>;
/** /**
* Default: todo activado. Si el JSONB en BD viene vacío o falta una * Default legacy (por contribuyente). Se mantiene por compatibilidad con la
* key, asumimos `true` para preservar el comportamiento previo. * columna `contribuyentes.email_preferences`; la UI nueva ya no lo usa.
*/ */
function applyDefaults(raw: Partial<Record<string, unknown>>): EmailPreferences { function applyDefaults(raw: Partial<Record<string, unknown>>): EmailPreferences {
const out = {} as EmailPreferences; const out = {} as EmailPreferences;
@@ -38,10 +57,10 @@ function sanitizeUuid(id: string): string {
return id.replace(/[^a-f0-9-]/gi, ''); return id.replace(/[^a-f0-9-]/gi, '');
} }
/** // ═══════════════════════════════════════════════════════════════════════════
* Lee las preferencias de un contribuyente. Devuelve defaults (todo // Preferencias por contribuyente (legacy — conservado por compatibilidad)
* activado) si no hay fila o la columna está vacía. // ═══════════════════════════════════════════════════════════════════════════
*/
export async function getContribuyenteEmailPreferences( export async function getContribuyenteEmailPreferences(
pool: Pool, pool: Pool,
contribuyenteId: string, contribuyenteId: string,
@@ -55,11 +74,6 @@ export async function getContribuyenteEmailPreferences(
return applyDefaults(raw); return applyDefaults(raw);
} }
/**
* Actualiza las preferencias de un contribuyente. Solo persiste las
* keys conocidas (filtra extras maliciosos). Merge sobre la columna
* existente (no sobreescribe keys no enviadas).
*/
export async function setContribuyenteEmailPreferences( export async function setContribuyenteEmailPreferences(
pool: Pool, pool: Pool,
contribuyenteId: string, contribuyenteId: string,
@@ -81,10 +95,6 @@ export async function setContribuyenteEmailPreferences(
return getContribuyenteEmailPreferences(pool, contribuyenteId); return getContribuyenteEmailPreferences(pool, contribuyenteId);
} }
/**
* Lee preferencias para múltiples contribuyentes en una sola query.
* Útil para la UI de `/configuracion/notificaciones` que lista todos.
*/
export async function getEmailPreferencesPorContribuyente( export async function getEmailPreferencesPorContribuyente(
pool: Pool, pool: Pool,
): Promise<Array<{ contribuyenteId: string; rfc: string; nombre: string; preferences: EmailPreferences }>> { ): Promise<Array<{ contribuyenteId: string; rfc: string; nombre: string; preferences: EmailPreferences }>> {
@@ -108,3 +118,89 @@ export async function getEmailPreferencesPorContribuyente(
preferences: applyDefaults(r.email_preferences ?? {}), preferences: applyDefaults(r.email_preferences ?? {}),
})); }));
} }
// ═══════════════════════════════════════════════════════════════════════════
// Preferencias por rol (nuevo modelo)
// ═══════════════════════════════════════════════════════════════════════════
function applyRoleDefaults(raw: Array<{ email_type: string; role: string; enabled: boolean }>): RoleEmailPreferences {
const out = {} as RoleEmailPreferences;
for (const t of EMAIL_TYPES) {
out[t] = {} as Record<NotificationRole, boolean>;
for (const r of NOTIFICATION_ROLES) {
const row = raw.find(x => x.email_type === t && x.role === r);
out[t][r] = row ? row.enabled : true;
}
}
return out;
}
/**
* Lee las preferencias de notificación por rol. Si la tabla está vacía para
* un (type, role), asume `true` para no romper el comportamiento previo.
*/
export async function getRoleEmailPreferences(pool: Pool): Promise<RoleEmailPreferences> {
const { rows } = await pool.query<{ email_type: string; role: string; enabled: boolean }>(
`SELECT email_type, role, enabled FROM notification_role_preferences`
);
return applyRoleDefaults(rows);
}
/**
* Actualiza una celda (emailType, role). Ignora valores desconocidos.
*/
export async function setRoleEmailPreference(
pool: Pool,
emailType: EmailType,
role: NotificationRole,
enabled: boolean,
): Promise<RoleEmailPreferences> {
await pool.query(
`INSERT INTO notification_role_preferences (email_type, role, enabled)
VALUES ($1, $2, $3)
ON CONFLICT (email_type, role) DO UPDATE SET enabled = EXCLUDED.enabled, updated_at = NOW()`,
[emailType, role, enabled],
);
return getRoleEmailPreferences(pool);
}
/**
* Devuelve true si el rol tiene habilitado el tipo de notificación.
* Fallback a true si no hay fila (comportamiento seguro).
*/
export async function isRoleEnabled(
pool: Pool,
emailType: EmailType,
role: NotificationRole,
): Promise<boolean> {
const { rows } = await pool.query<{ enabled: boolean }>(
`SELECT enabled FROM notification_role_preferences WHERE email_type = $1 AND role = $2`,
[emailType, role],
);
return rows[0]?.enabled ?? true;
}
interface RecipientWithRole {
email: string;
role: NotificationRole;
}
/**
* Filtra una lista de destinatarios con rol según las preferencias guardadas.
* Si no hay preferencias para un (type, role), se conserva el destinatario.
*/
export async function filterRecipientsByRole(
pool: Pool,
emailType: EmailType,
recipients: RecipientWithRole[],
): Promise<string[]> {
const prefs = await getRoleEmailPreferences(pool);
const typePrefs = prefs[emailType];
const filtered = recipients.filter(r => {
if (!typePrefs) return true;
return typePrefs[r.role] !== false;
});
return [...new Set(filtered.map(r => r.email))];
}
export type { RecipientWithRole };

View File

@@ -26,6 +26,12 @@ import { generarAlertasAutomaticas, type AlertaAuto } from './alertas-auto.servi
import { emailService } from './email/email.service.js'; import { emailService } from './email/email.service.js';
import type { AlertaItem } from './email/templates/alertas-nuevas.js'; import type { AlertaItem } from './email/templates/alertas-nuevas.js';
import type { VentanaRecordatorio } from './email/templates/recordatorio-proximo.js'; import type { VentanaRecordatorio } from './email/templates/recordatorio-proximo.js';
import {
filterRecipientsByRole,
type RecipientWithRole,
type EmailType,
type NotificationRole,
} from './notification-preferences.service.js';
const FRONTEND_URL = process.env.FRONTEND_URL || 'http://localhost:3000'; const FRONTEND_URL = process.env.FRONTEND_URL || 'http://localhost:3000';
@@ -100,39 +106,60 @@ async function getUserContacts(userIds: string[]): Promise<UserContact[]> {
/** /**
* Destinatarios de una alerta: supervisor + auxiliares + clientes del * Destinatarios de una alerta: supervisor + auxiliares + clientes del
* contribuyente. Si el owner del tenant es supervisor, ya queda incluido * contribuyente. Retorna emails con su rol para poder filtrar por
* (no se duplica). * preferencias de notificación.
*/ */
async function recipientsForAlerta( async function recipientsForAlerta(
pool: Pool, pool: Pool,
tenantId: string, tenantId: string,
contribuyenteId: string, contribuyenteId: string,
): Promise<string[]> { ): Promise<RecipientWithRole[]> {
const ids = await getUserIdsContribuyente(pool, contribuyenteId); const ids = await getUserIdsContribuyente(pool, contribuyenteId);
const userIds = new Set<string>(); const byRole = new Map<string, NotificationRole>();
if (ids.supervisor) userIds.add(ids.supervisor); if (ids.supervisor) byRole.set(ids.supervisor, 'supervisor');
ids.auxiliares.forEach(id => userIds.add(id)); ids.auxiliares.forEach(id => byRole.set(id, 'auxiliar'));
ids.clientes.forEach(id => userIds.add(id)); ids.clientes.forEach(id => byRole.set(id, 'cliente'));
const contacts = await getUserContacts([...userIds]);
return [...new Set(contacts.map(c => c.email))]; const contacts = await getUserContacts([...byRole.keys()]);
return contacts
.filter(c => byRole.has(c.userId))
.map(c => ({ email: c.email, role: byRole.get(c.userId)! }));
}
async function getUserRole(
tenantId: string,
userId: string,
): Promise<NotificationRole | null> {
const m = await prisma.tenantMembership.findFirst({
where: { userId, tenantId, active: true },
include: { rol: { select: { nombre: true } } },
});
if (!m) return null;
const role = m.rol.nombre;
if (role === 'owner' || role === 'supervisor' || role === 'auxiliar' || role === 'cliente') {
return role;
}
return null;
} }
/** /**
* Destinatarios de un recordatorio. Los recordatorios del despacho son * Destinatarios de un recordatorio. Los recordatorios del despacho son
* tenant-level (no atados a contribuyente). Para públicos: clientes con * tenant-level (no atados a contribuyente). Retorna emails con rol para
* algún acceso + auxiliares de cualquier cartera; si no hay auxiliares, * filtrado por preferencias.
* supervisores; si owner aparece como supervisor, también recibe.
* *
* Públicos: clientes + auxiliares + supervisores + owners.
* Privados: solo el creador. * Privados: solo el creador.
*/ */
async function recipientsForRecordatorio( async function recipientsForRecordatorio(
pool: Pool, pool: Pool,
tenantId: string, tenantId: string,
recordatorio: { creadoPor: string; privado: boolean }, recordatorio: { creadoPor: string; privado: boolean },
): Promise<string[]> { ): Promise<RecipientWithRole[]> {
if (recordatorio.privado) { if (recordatorio.privado) {
const role = await getUserRole(tenantId, recordatorio.creadoPor);
if (!role) return [];
const contacts = await getUserContacts([recordatorio.creadoPor]); const contacts = await getUserContacts([recordatorio.creadoPor]);
return [...new Set(contacts.map(c => c.email))]; return contacts.map(c => ({ email: c.email, role }));
} }
// Recordatorio público: lee universos relevantes del tenant. // Recordatorio público: lee universos relevantes del tenant.
@@ -158,27 +185,19 @@ async function recipientsForRecordatorio(
), ARRAY[]::uuid[]) AS cliente_user_ids ), ARRAY[]::uuid[]) AS cliente_user_ids
`); `);
const auxiliares = r?.auxiliar_user_ids ?? []; const byRole = new Map<string, NotificationRole>();
const supervisores = r?.supervisor_user_ids ?? []; (r?.auxiliar_user_ids ?? []).forEach(id => byRole.set(id, 'auxiliar'));
const clientes = r?.cliente_user_ids ?? []; (r?.supervisor_user_ids ?? []).forEach(id => byRole.set(id, 'supervisor'));
(r?.cliente_user_ids ?? []).forEach(id => byRole.set(id, 'cliente'));
// Owners siempre se consideran owner aunque también aparezcan como supervisor.
const owners = await getOwnerUserIds(tenantId); const owners = await getOwnerUserIds(tenantId);
owners.forEach(id => byRole.set(id, 'owner'));
// Regla del owner: clientes y auxiliares siempre. Si no hay auxiliares, const contacts = await getUserContacts([...byRole.keys()]);
// agregar supervisores. Si owner es supervisor y no hay auxiliares, return contacts
// owner queda incluido vía la lista de supervisores. .filter(c => byRole.has(c.userId))
const userIds = new Set<string>(); .map(c => ({ email: c.email, role: byRole.get(c.userId)! }));
clientes.forEach(id => userIds.add(id));
auxiliares.forEach(id => userIds.add(id));
if (auxiliares.length === 0) {
supervisores.forEach(id => userIds.add(id));
// Solo si owner aparece como supervisor (intersección):
for (const ownerId of owners) {
if (supervisores.includes(ownerId)) userIds.add(ownerId);
}
}
const contacts = await getUserContacts([...userIds]);
return [...new Set(contacts.map(c => c.email))];
} }
// ──────────────────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────────────────
@@ -276,8 +295,10 @@ async function processAlertasContribuyente(
return { nuevas: 0, resueltas }; return { nuevas: 0, resueltas };
} }
// Envía email batched a los responsables del contribuyente. // Envía email batched a los responsables del contribuyente, filtrando por
const recipients = await recipientsForAlerta(pool, tenantId, contribuyente.entidadId); // preferencias de rol para alertas_nuevas.
const recipientsWithRole = await recipientsForAlerta(pool, tenantId, contribuyente.entidadId);
const recipients = await filterRecipientsByRole(pool, 'alertas_nuevas', recipientsWithRole);
if (recipients.length === 0) { if (recipients.length === 0) {
console.warn(`[Notifications] Sin destinatarios para alertas de ${contribuyente.rfc} (tenant ${tenant.rfc})`); console.warn(`[Notifications] Sin destinatarios para alertas de ${contribuyente.rfc} (tenant ${tenant.rfc})`);
return { nuevas: nuevas.length, resueltas }; return { nuevas: nuevas.length, resueltas };
@@ -361,10 +382,11 @@ export async function processProximosRecordatorios(
for (const r of rows) { for (const r of rows) {
try { try {
const recipients = await recipientsForRecordatorio(pool, tenantId, { const recipientsWithRole = await recipientsForRecordatorio(pool, tenantId, {
creadoPor: r.creado_por, creadoPor: r.creado_por,
privado: r.privado, privado: r.privado,
}); });
const recipients = await filterRecipientsByRole(pool, 'recordatorio_proximo', recipientsWithRole);
if (recipients.length === 0) { if (recipients.length === 0) {
console.warn(`[Notifications] Recordatorio ${r.id} (${tenant.rfc}) sin destinatarios — skip ${ventana}`); console.warn(`[Notifications] Recordatorio ${r.id} (${tenant.rfc}) sin destinatarios — skip ${ventana}`);
continue; continue;

View File

@@ -3,8 +3,12 @@ import { prisma } from '../config/database.js';
import { emailService } from './email/email.service.js'; import { emailService } from './email/email.service.js';
import { getTenantOwnerEmails, getUserEmailById } from '../utils/memberships.js'; import { getTenantOwnerEmails, getUserEmailById } from '../utils/memberships.js';
import { env } from '../config/env.js'; import { env } from '../config/env.js';
import { getContribuyenteEmailPreferences } from './notification-preferences.service.js'; import { filterRecipientsByRole, type RecipientWithRole } from './notification-preferences.service.js';
import type { DocumentoSubidoData } from './email/templates/documento-subido.js'; import type { DocumentoSubidoData } from './email/templates/documento-subido.js';
import type { EmailAttachment } from '@horux/core';
/** Límite total de adjuntos para evitar rechazos por SMTP (20 MB). */
const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024;
/** /**
* Notifica a los destinatarios relevantes cuando se sube una declaración * Notifica a los destinatarios relevantes cuando se sube una declaración
@@ -26,7 +30,11 @@ export async function notifyDocumentoSubido(params: {
subidoPor: string; subidoPor: string;
kind: DocumentoSubidoData['kind']; kind: DocumentoSubidoData['kind'];
declaracion?: DocumentoSubidoData['declaracion']; declaracion?: DocumentoSubidoData['declaracion'];
declaracionId?: number;
extra?: DocumentoSubidoData['extra']; extra?: DocumentoSubidoData['extra'];
evidencia?: DocumentoSubidoData['evidencia'];
/** PDF en base64 para adjuntar en notificaciones de evidencia de obligación. */
pdfBase64?: string;
}): Promise<void> { }): Promise<void> {
const { pool, tenantId, contribuyenteId, subidoPor } = params; const { pool, tenantId, contribuyenteId, subidoPor } = params;
@@ -34,10 +42,7 @@ export async function notifyDocumentoSubido(params: {
// subject informativo ni supervisor — skip. // subject informativo ni supervisor — skip.
if (!contribuyenteId) return; if (!contribuyenteId) return;
// Respeta preferencias de notificación del contribuyente. Si el user
// desactivó `documento_subido` para este contribuyente, no enviar.
const prefs = await getContribuyenteEmailPreferences(pool, contribuyenteId);
if (!prefs.documento_subido) return;
const { rows } = await pool.query<{ const { rows } = await pool.query<{
rfc: string; rfc: string;
@@ -54,14 +59,17 @@ export async function notifyDocumentoSubido(params: {
const contrib = rows[0]; const contrib = rows[0];
// 2. Recipients. Owners primero; luego supervisor si aplica. // 2. Recipients. Owners primero; luego supervisor si aplica.
const owners = await getTenantOwnerEmails(tenantId); const ownerEmails = await getTenantOwnerEmails(tenantId);
const recipients = new Set<string>(owners); const recipientsWithRole: RecipientWithRole[] = ownerEmails.map(email => ({ email, role: 'owner' }));
if (contrib.supervisor_user_id) { if (contrib.supervisor_user_id) {
const supervisorEmail = await getUserEmailById(contrib.supervisor_user_id); const supervisorEmail = await getUserEmailById(contrib.supervisor_user_id);
if (supervisorEmail) recipients.add(supervisorEmail); if (supervisorEmail) recipientsWithRole.push({ email: supervisorEmail, role: 'supervisor' });
} }
// Filtra por preferencias de rol para documento_subido.
const recipients = new Set(await filterRecipientsByRole(pool, 'documento_subido', recipientsWithRole));
// Excluir al uploader: no notificarle su propia acción. // Excluir al uploader: no notificarle su propia acción.
recipients.delete(subidoPor.toLowerCase()); recipients.delete(subidoPor.toLowerCase());
recipients.delete(subidoPor); recipients.delete(subidoPor);
@@ -77,6 +85,23 @@ export async function notifyDocumentoSubido(params: {
// 4. Link al sistema. Usa FRONTEND_URL del env. // 4. Link al sistema. Usa FRONTEND_URL del env.
const link = `${env.FRONTEND_URL}/documentos`; const link = `${env.FRONTEND_URL}/documentos`;
// Adjuntar los PDFs cuando se trata de una declaración recién creada o de una evidencia de obligación.
let attachments: EmailAttachment[] | undefined;
let attachmentsOmitted = false;
if (params.kind === 'declaracion' && params.declaracionId) {
const built = await buildDeclaracionAttachments(pool, params.declaracionId);
attachments = built.attachments;
attachmentsOmitted = built.omitted;
} else if (params.kind === 'obligacion_evidencia' && params.pdfBase64 && params.evidencia) {
const content = Buffer.from(params.pdfBase64, 'base64');
if (content.length > MAX_ATTACHMENT_BYTES) {
attachmentsOmitted = true;
console.warn(`[notifyDocumentoSubido] Evidencia de obligación excede ${MAX_ATTACHMENT_BYTES} bytes (${content.length}). Se envía sin adjunto.`);
} else {
attachments = [{ filename: params.evidencia.filename, content }];
}
}
await emailService.sendDocumentoSubido(Array.from(recipients), { await emailService.sendDocumentoSubido(Array.from(recipients), {
kind: params.kind, kind: params.kind,
subidoPor, subidoPor,
@@ -85,6 +110,46 @@ export async function notifyDocumentoSubido(params: {
despachoNombre: tenant?.nombre, despachoNombre: tenant?.nombre,
declaracion: params.declaracion, declaracion: params.declaracion,
extra: params.extra, extra: params.extra,
evidencia: params.evidencia,
link, link,
}); attachmentsOmitted,
}, attachments);
}
async function buildDeclaracionAttachments(
pool: Pool,
declaracionId: number,
): Promise<{ attachments?: EmailAttachment[]; omitted: boolean }> {
const { rows } = await pool.query(
`SELECT pdf_declaracion, pdf_filename,
pdf_liga_pago, pdf_liga_pago_filename
FROM declaraciones_provisionales
WHERE id = $1`,
[declaracionId],
);
const row = rows[0];
if (!row) return { omitted: false };
let totalSize = 0;
const attachments: EmailAttachment[] = [];
if (row.pdf_declaracion && row.pdf_filename) {
const content = Buffer.from(row.pdf_declaracion);
totalSize += content.length;
attachments.push({ filename: row.pdf_filename, content });
}
if (row.pdf_liga_pago && row.pdf_liga_pago_filename) {
const content = Buffer.from(row.pdf_liga_pago);
totalSize += content.length;
attachments.push({ filename: row.pdf_liga_pago_filename, content });
}
if (totalSize > MAX_ATTACHMENT_BYTES) {
console.warn(`[notifyDocumentoSubido] Adjuntos de declaración ${declaracionId} exceden ${MAX_ATTACHMENT_BYTES} bytes (${totalSize}). Se envía sin adjuntos.`);
return { omitted: true };
}
return { attachments, omitted: false };
} }

View File

@@ -0,0 +1,272 @@
import type { Pool } from 'pg';
import { OBLIGACIONES_CATALOGO } from '../constants/obligaciones-fiscales.js';
export interface EvidenciaRow {
id: number;
obligacionId: string;
periodo: string;
contribuyenteId: string;
tipoDocumento: 'declaracion' | 'pago' | 'acuse' | 'complemento';
archivo: Buffer;
archivoFilename: string;
archivoMime: string;
notas: string | null;
subidoPor: string | null;
subidoPorEmail: string | null;
createdAt: string;
}
export interface CreateEvidenciaInput {
obligacionId: string;
periodo: string;
contribuyenteId: string;
tipoDocumento: 'declaracion' | 'pago' | 'acuse' | 'complemento';
pdfBase64: string;
pdfFilename: string;
notas?: string;
subidoPor: string; // userId UUID
subidoPorEmail?: string;
}
function rowToEvidencia(r: any): EvidenciaRow {
return {
id: r.id,
obligacionId: r.obligacion_id,
periodo: r.periodo,
contribuyenteId: r.contribuyente_id,
tipoDocumento: r.tipo_documento,
archivo: Buffer.from(r.archivo),
archivoFilename: r.archivo_filename,
archivoMime: r.archivo_mime,
notas: r.notas,
subidoPor: r.subido_por,
subidoPorEmail: r.subido_por_email,
createdAt: r.created_at.toISOString(),
};
}
async function getObligacionContribuyente(pool: Pool, obligacionId: string): Promise<{ contribuyenteId: string; catalogoId: string | null } | null> {
const { rows } = await pool.query<{ contribuyente_id: string; catalogo_id: string | null }>(
`SELECT contribuyente_id, catalogo_id FROM obligaciones_contribuyente WHERE id = $1`,
[obligacionId],
);
const row = rows[0];
if (!row) return null;
return { contribuyenteId: row.contribuyente_id, catalogoId: row.catalogo_id };
}
function requierePago(obligacion: { catalogoId: string | null }): boolean {
if (!obligacion.catalogoId) return true; // conservador: sin catálogo, requiere pago
const catalogo = OBLIGACIONES_CATALOGO.find((o) => o.id === obligacion.catalogoId);
return catalogo?.requierePago ?? true;
}
function esDocumentoDeclaracion(tipo: string): boolean {
return tipo === 'declaracion' || tipo === 'acuse' || tipo === 'complemento';
}
async function updatePeriodoStatus(
pool: Pool,
obligacionId: string,
periodo: string,
tipoDocumento: string,
reqPago: boolean,
completadaPor: string,
notas?: string,
): Promise<{ completada: boolean; declaracionPresentada: boolean; pagoPresentado: boolean }> {
const { rows } = await pool.query<{
declaracion_presentada: boolean;
pago_presentado: boolean;
completada: boolean;
}>(
`SELECT declaracion_presentada, pago_presentado, completada
FROM obligacion_periodos
WHERE obligacion_id = $1 AND periodo = $2`,
[obligacionId, periodo],
);
const existing = rows[0];
let declaracionPresentada = existing?.declaracion_presentada ?? false;
let pagoPresentado = existing?.pago_presentado ?? false;
if (esDocumentoDeclaracion(tipoDocumento)) declaracionPresentada = true;
if (tipoDocumento === 'pago') pagoPresentado = true;
const completada = !reqPago || pagoPresentado;
const now = new Date();
if (existing) {
await pool.query(
`UPDATE obligacion_periodos
SET declaracion_presentada = $3,
pago_presentado = $4,
completada = $5,
completada_at = CASE WHEN $5 THEN COALESCE(completada_at, $6) ELSE completada_at END,
completada_por = CASE WHEN $5 THEN COALESCE(completada_por, $7) ELSE completada_por END,
notas = COALESCE($8, notas)
WHERE obligacion_id = $1 AND periodo = $2`,
[obligacionId, periodo, declaracionPresentada, pagoPresentado, completada, now, completadaPor, notas ?? null],
);
} else {
await pool.query(
`INSERT INTO obligacion_periodos
(obligacion_id, periodo, declaracion_presentada, pago_presentado, completada, completada_at, completada_por, notas)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
[obligacionId, periodo, declaracionPresentada, pagoPresentado, completada, completada ? now : null, completada ? completadaPor : null, notas ?? null],
);
}
if (completada) {
await pool.query(
`UPDATE alertas SET resuelta = true WHERE tipo = $1 AND resuelta = false`,
[`ob-${obligacionId}-${periodo}`],
);
}
return { completada, declaracionPresentada, pagoPresentado };
}
async function recalcPeriodoStatus(
pool: Pool,
obligacionId: string,
periodo: string,
reqPago: boolean,
): Promise<void> {
const { rows } = await pool.query<{ tipo_documento: string }>(
`SELECT tipo_documento FROM obligacion_evidencias WHERE obligacion_id = $1 AND periodo = $2`,
[obligacionId, periodo],
);
const declaracionPresentada = rows.some((r) => esDocumentoDeclaracion(r.tipo_documento));
const pagoPresentado = rows.some((r) => r.tipo_documento === 'pago');
const completada = !reqPago || pagoPresentado;
await pool.query(
`UPDATE obligacion_periodos
SET declaracion_presentada = $3,
pago_presentado = $4,
completada = $5,
completada_at = CASE WHEN $5 THEN COALESCE(completada_at, NOW()) ELSE completada_at END
WHERE obligacion_id = $1 AND periodo = $2`,
[obligacionId, periodo, declaracionPresentada, pagoPresentado, completada],
);
}
export async function createEvidencia(
pool: Pool,
data: CreateEvidenciaInput,
): Promise<{ evidencia: EvidenciaRow; completada: boolean; declaracionPresentada: boolean; pagoPresentado: boolean }> {
const obligacion = await getObligacionContribuyente(pool, data.obligacionId);
if (!obligacion) throw new Error('Obligación no encontrada');
if (obligacion.contribuyenteId !== data.contribuyenteId) throw new Error('La obligación no pertenece al contribuyente');
const reqPago = requierePago(obligacion);
const archivo = Buffer.from(data.pdfBase64, 'base64');
const { rows } = await pool.query(
`INSERT INTO obligacion_evidencias
(obligacion_id, periodo, contribuyente_id, tipo_documento, archivo, archivo_filename, archivo_mime, notas, subido_por, subido_por_email)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING id, obligacion_id, periodo, contribuyente_id, tipo_documento, archivo, archivo_filename, archivo_mime,
notas, subido_por, subido_por_email, created_at`,
[data.obligacionId, data.periodo, data.contribuyenteId, data.tipoDocumento, archivo, data.pdfFilename, 'application/pdf', data.notas ?? null, data.subidoPor, data.subidoPorEmail],
);
const status = await updatePeriodoStatus(
pool,
data.obligacionId,
data.periodo,
data.tipoDocumento,
reqPago,
data.subidoPor,
data.notas,
);
return { evidencia: rowToEvidencia(rows[0]), ...status };
}
export async function listEvidencias(
pool: Pool,
contribuyenteId: string,
filters?: { periodo?: string; obligacionId?: string },
): Promise<EvidenciaRow[]> {
const conditions: string[] = ['contribuyente_id = $1'];
const params: unknown[] = [contribuyenteId];
if (filters?.periodo) {
params.push(filters.periodo);
conditions.push(`periodo = $${params.length}`);
}
if (filters?.obligacionId) {
params.push(filters.obligacionId);
conditions.push(`obligacion_id = $${params.length}`);
}
const { rows } = await pool.query(
`SELECT id, obligacion_id, periodo, contribuyente_id, tipo_documento, archivo, archivo_filename, archivo_mime,
notas, subido_por, subido_por_email, created_at
FROM obligacion_evidencias
WHERE ${conditions.join(' AND ')}
ORDER BY created_at DESC`,
params,
);
return rows.map(rowToEvidencia);
}
export async function getEvidenciaPdf(
pool: Pool,
id: number,
): Promise<{ buffer: Buffer; filename: string; mime: string } | null> {
const { rows } = await pool.query(
`SELECT archivo, archivo_filename, archivo_mime FROM obligacion_evidencias WHERE id = $1`,
[id],
);
if (rows.length === 0 || !rows[0].archivo) return null;
return {
buffer: Buffer.from(rows[0].archivo),
filename: rows[0].archivo_filename || `evidencia-${id}.pdf`,
mime: rows[0].archivo_mime || 'application/pdf',
};
}
export async function deleteEvidencia(
pool: Pool,
id: number,
): Promise<{ obligacionId: string; periodo: string } | null> {
const { rows } = await pool.query<{ obligacion_id: string; periodo: string }>(
`DELETE FROM obligacion_evidencias WHERE id = $1 RETURNING obligacion_id, periodo`,
[id],
);
if (rows.length === 0) return null;
const { obligacion_id: obligacionId, periodo } = rows[0];
const obligacion = await getObligacionContribuyente(pool, obligacionId);
if (obligacion) {
const reqPago = requierePago(obligacion);
await recalcPeriodoStatus(pool, obligacionId, periodo, reqPago);
}
return { obligacionId, periodo };
}
export async function getPeriodoStatus(
pool: Pool,
obligacionId: string,
periodo: string,
): Promise<{ completada: boolean; declaracionPresentada: boolean; pagoPresentado: boolean } | null> {
const { rows } = await pool.query<{
completada: boolean;
declaracion_presentada: boolean;
pago_presentado: boolean;
}>(
`SELECT completada, declaracion_presentada, pago_presentado
FROM obligacion_periodos
WHERE obligacion_id = $1 AND periodo = $2`,
[obligacionId, periodo],
);
if (rows.length === 0) return null;
return {
completada: rows[0].completada,
declaracionPresentada: rows[0].declaracion_presentada,
pagoPresentado: rows[0].pago_presentado,
};
}

View File

@@ -1,6 +1,11 @@
import type { Pool } from 'pg'; import type { Pool } from 'pg';
import { OBLIGACIONES_CATALOGO, getRecomendaciones, type ObligacionFiscal } from '../constants/obligaciones-fiscales.js'; import { OBLIGACIONES_CATALOGO, getRecomendaciones, type ObligacionFiscal } from '../constants/obligaciones-fiscales.js';
function requierePagoPorCatalogo(catalogoId: string | null): boolean {
if (!catalogoId) return true;
return OBLIGACIONES_CATALOGO.find((o) => o.id === catalogoId)?.requierePago ?? true;
}
/** /**
* Keyword-based matching: each catalog entry has discriminant keywords * Keyword-based matching: each catalog entry has discriminant keywords
* that must ALL appear in the SAT description (normalized, lowercase, no accents). * that must ALL appear in the SAT description (normalized, lowercase, no accents).
@@ -138,6 +143,8 @@ export interface ObligacionContribuyente {
completadaPor: string | null; completadaPor: string | null;
periodoCompletado: string | null; periodoCompletado: string | null;
createdAt?: string; createdAt?: string;
auxiliarAsignadoId?: string | null;
auxiliarAsignadoNombre?: string | null;
} }
export function getCatalogo(): ObligacionFiscal[] { export function getCatalogo(): ObligacionFiscal[] {
@@ -146,15 +153,18 @@ export function getCatalogo(): ObligacionFiscal[] {
export async function getObligaciones(pool: Pool, contribuyenteId: string): Promise<ObligacionContribuyente[]> { export async function getObligaciones(pool: Pool, contribuyenteId: string): Promise<ObligacionContribuyente[]> {
const { rows } = await pool.query(` const { rows } = await pool.query(`
SELECT id, contribuyente_id AS "contribuyenteId", catalogo_id AS "catalogoId", SELECT
nombre, fundamento, frecuencia, fecha_limite AS "fechaLimite", categoria, oc.id, oc.contribuyente_id AS "contribuyenteId", oc.catalogo_id AS "catalogoId",
activa, es_recomendada AS "esRecomendada", es_custom AS "esCustom", oc.nombre, oc.fundamento, oc.frecuencia, oc.fecha_limite AS "fechaLimite", oc.categoria,
completada, completada_at AS "completadaAt", completada_por AS "completadaPor", oc.activa, oc.es_recomendada AS "esRecomendada", oc.es_custom AS "esCustom",
periodo_completado AS "periodoCompletado", oc.completada, oc.completada_at AS "completadaAt", oc.completada_por AS "completadaPor",
created_at AS "createdAt" oc.periodo_completado AS "periodoCompletado",
FROM obligaciones_contribuyente oc.created_at AS "createdAt",
WHERE contribuyente_id = $1 oa.auxiliar_user_id AS "auxiliarAsignadoId"
ORDER BY categoria, nombre FROM obligaciones_contribuyente oc
LEFT JOIN obligacion_asignaciones oa ON oa.obligacion_id = oc.id
WHERE oc.contribuyente_id = $1
ORDER BY oc.categoria, oc.nombre
`, [contribuyenteId]); `, [contribuyenteId]);
return rows; return rows;
} }
@@ -250,6 +260,7 @@ export async function initRecomendaciones(
function inferirFrecuencia(vencimiento: string): string { function inferirFrecuencia(vencimiento: string): string {
const lower = vencimiento.toLowerCase(); const lower = vencimiento.toLowerCase();
if (lower.includes('mensual') || lower.includes('mes')) return 'mensual'; if (lower.includes('mensual') || lower.includes('mes')) return 'mensual';
if (lower.includes('cuatrimest')) return 'cuatrimestral';
if (lower.includes('bimest')) return 'bimestral'; if (lower.includes('bimest')) return 'bimestral';
if (lower.includes('trimest')) return 'trimestral'; if (lower.includes('trimest')) return 'trimestral';
if (lower.includes('anual') || lower.includes('ejercicio') || lower.includes('tres meses siguientes')) return 'anual'; if (lower.includes('anual') || lower.includes('ejercicio') || lower.includes('tres meses siguientes')) return 'anual';
@@ -346,13 +357,22 @@ export async function getObligacionesPorPeriodo(
const [year, month] = periodo.split('-').map(Number); const [year, month] = periodo.split('-').map(Number);
const currentPeriodo = new Date().toISOString().substring(0, 7); const currentPeriodo = new Date().toISOString().substring(0, 7);
const results: Array<ObligacionContribuyente & { periodStatus: string; periodoAplica: string; declaracion: DeclaracionLink | null }> = []; const results: Array<ObligacionContribuyente & {
periodStatus: string;
periodoAplica: string;
declaracion: DeclaracionLink | null;
declaracionPresentada: boolean;
pagoPresentado: boolean;
requierePago: boolean;
}> = [];
// Get all completion records + associated declaration info for this contribuyente // Get all completion records + associated declaration info for this contribuyente
const { rows: completions } = await pool.query<{ const { rows: completions } = await pool.query<{
obligacion_id: string; obligacion_id: string;
periodo: string; periodo: string;
completada: boolean; completada: boolean;
declaracion_presentada: boolean;
pago_presentado: boolean;
declaracion_id: number | null; declaracion_id: number | null;
decl_año: number | null; decl_año: number | null;
decl_mes: number | null; decl_mes: number | null;
@@ -360,6 +380,7 @@ export async function getObligacionesPorPeriodo(
decl_pdf_filename: string | null; decl_pdf_filename: string | null;
}>(` }>(`
SELECT op.obligacion_id, op.periodo, op.completada, SELECT op.obligacion_id, op.periodo, op.completada,
op.declaracion_presentada, op.pago_presentado,
op.declaracion_id, op.declaracion_id,
dp.año AS decl_año, dp.año AS decl_año,
dp.mes AS decl_mes, dp.mes AS decl_mes,
@@ -372,10 +393,14 @@ export async function getObligacionesPorPeriodo(
`, [contribuyenteId]); `, [contribuyenteId]);
const completionMap = new Map<string, boolean>(); const completionMap = new Map<string, boolean>();
const declaracionPresentadaMap = new Map<string, boolean>();
const pagoPresentadoMap = new Map<string, boolean>();
const declaracionMap = new Map<string, DeclaracionLink | null>(); const declaracionMap = new Map<string, DeclaracionLink | null>();
for (const c of completions) { for (const c of completions) {
const key = `${c.obligacion_id}:${c.periodo}`; const key = `${c.obligacion_id}:${c.periodo}`;
completionMap.set(key, c.completada); completionMap.set(key, c.completada);
declaracionPresentadaMap.set(key, c.declaracion_presentada);
pagoPresentadoMap.set(key, c.pago_presentado);
if (c.declaracion_id && c.decl_año != null && c.decl_mes != null && c.decl_tipo) { if (c.declaracion_id && c.decl_año != null && c.decl_mes != null && c.decl_tipo) {
declaracionMap.set(key, { declaracionMap.set(key, {
id: c.declaracion_id, id: c.declaracion_id,
@@ -402,6 +427,9 @@ export async function getObligacionesPorPeriodo(
periodStatus: isCompleted ? 'completada' : 'pendiente', periodStatus: isCompleted ? 'completada' : 'pendiente',
periodoAplica: periodo, periodoAplica: periodo,
declaracion: declaracionMap.get(key) ?? null, declaracion: declaracionMap.get(key) ?? null,
declaracionPresentada: declaracionPresentadaMap.get(key) === true,
pagoPresentado: pagoPresentadoMap.get(key) === true,
requierePago: requierePagoPorCatalogo(ob.catalogoId),
}); });
} }
@@ -429,6 +457,9 @@ export async function getObligacionesPorPeriodo(
periodStatus: 'atrasada', periodStatus: 'atrasada',
periodoAplica: pastPeriodo, periodoAplica: pastPeriodo,
declaracion: null, declaracion: null,
declaracionPresentada: declaracionPresentadaMap.get(pastKey) === true,
pagoPresentado: pagoPresentadoMap.get(pastKey) === true,
requierePago: requierePagoPorCatalogo(ob.catalogoId),
}); });
} }
} }
@@ -443,7 +474,14 @@ export async function getObligacionesPorPeriodo(
return a.nombre.localeCompare(b.nombre); return a.nombre.localeCompare(b.nombre);
}); });
return results as Array<ObligacionContribuyente & { periodStatus: 'pendiente' | 'completada' | 'atrasada'; periodoAplica: string; declaracion: DeclaracionLink | null }>; return results as Array<ObligacionContribuyente & {
periodStatus: 'pendiente' | 'completada' | 'atrasada';
periodoAplica: string;
declaracion: DeclaracionLink | null;
declaracionPresentada: boolean;
pagoPresentado: boolean;
requierePago: boolean;
}>;
} }
function appliesTo(frecuencia: string | null, periodo: string): boolean { function appliesTo(frecuencia: string | null, periodo: string): boolean {
@@ -452,6 +490,7 @@ function appliesTo(frecuencia: string | null, periodo: string): boolean {
case 'mensual': return true; case 'mensual': return true;
case 'bimestral': return month % 2 === 1; // Jan, Mar, May... case 'bimestral': return month % 2 === 1; // Jan, Mar, May...
case 'trimestral': return [1, 4, 7, 10].includes(month); case 'trimestral': return [1, 4, 7, 10].includes(month);
case 'cuatrimestral': return [1, 5, 9].includes(month);
case 'anual': return month === 3 || month === 4; // March (PM) or April (PF) — show in both case 'anual': return month === 3 || month === 4; // March (PM) or April (PF) — show in both
case 'eventual': return false; // Don't auto-show case 'eventual': return false; // Don't auto-show
default: return true; default: return true;

View File

@@ -27,6 +27,11 @@ export interface PapeleriaItem {
aprobadoPor: string | null; aprobadoPor: string | null;
aprobadoAt: Date | null; aprobadoAt: Date | null;
comentarioRechazo: string | null; comentarioRechazo: string | null;
requiereAprobacionCliente: boolean;
estadoCliente: EstadoPapeleria | null;
aprobadoPorCliente: string | null;
aprobadoAtCliente: Date | null;
comentarioRechazoCliente: string | null;
subidoPor: string; subidoPor: string;
createdAt: Date; createdAt: Date;
} }
@@ -36,6 +41,7 @@ const SELECT = `
archivo_filename, archivo_mime, archivo_size, archivo_filename, archivo_mime, archivo_size,
anio, mes, anio, mes,
requiere_aprobacion, estado, aprobado_por, aprobado_at, comentario_rechazo, requiere_aprobacion, estado, aprobado_por, aprobado_at, comentario_rechazo,
requiere_aprobacion_cliente, estado_cliente, aprobado_por_cliente, aprobado_at_cliente, comentario_rechazo_cliente,
subido_por, created_at subido_por, created_at
`; `;
@@ -54,6 +60,11 @@ const ROW = (r: any): PapeleriaItem => ({
aprobadoPor: r.aprobado_por, aprobadoPor: r.aprobado_por,
aprobadoAt: r.aprobado_at, aprobadoAt: r.aprobado_at,
comentarioRechazo: r.comentario_rechazo, comentarioRechazo: r.comentario_rechazo,
requiereAprobacionCliente: r.requiere_aprobacion_cliente,
estadoCliente: r.estado_cliente,
aprobadoPorCliente: r.aprobado_por_cliente,
aprobadoAtCliente: r.aprobado_at_cliente,
comentarioRechazoCliente: r.comentario_rechazo_cliente,
subidoPor: r.subido_por, subidoPor: r.subido_por,
createdAt: r.created_at, createdAt: r.created_at,
}); });
@@ -69,6 +80,7 @@ export interface UploadInput {
anio: number; anio: number;
mes: number; mes: number;
requiereAprobacion: boolean; requiereAprobacion: boolean;
requiereAprobacionCliente: boolean;
archivo: Buffer; archivo: Buffer;
archivoFilename: string; archivoFilename: string;
archivoMime: string; archivoMime: string;
@@ -87,12 +99,13 @@ export async function uploadPapeleria(
} }
const estadoInicial = input.requiereAprobacion ? 'pendiente' : null; const estadoInicial = input.requiereAprobacion ? 'pendiente' : null;
const estadoClienteInicial = input.requiereAprobacionCliente ? 'pendiente' : null;
const { rows: [r] } = await pool.query( const { rows: [r] } = await pool.query(
`INSERT INTO papeleria_trabajo `INSERT INTO papeleria_trabajo
(contribuyente_id, nombre, descripcion, archivo, archivo_filename, archivo_mime, archivo_size, (contribuyente_id, nombre, descripcion, archivo, archivo_filename, archivo_mime, archivo_size,
anio, mes, requiere_aprobacion, estado, subido_por) anio, mes, requiere_aprobacion, estado, requiere_aprobacion_cliente, estado_cliente, subido_por)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
RETURNING ${SELECT}`, RETURNING ${SELECT}`,
[ [
sanitizeUuid(input.contribuyenteId), sanitizeUuid(input.contribuyenteId),
@@ -106,6 +119,8 @@ export async function uploadPapeleria(
input.mes, input.mes,
input.requiereAprobacion, input.requiereAprobacion,
estadoInicial, estadoInicial,
input.requiereAprobacionCliente,
estadoClienteInicial,
input.subidoPor, input.subidoPor,
], ],
); );
@@ -117,6 +132,8 @@ export interface ListFilters {
anio?: number; anio?: number;
mes?: number; mes?: number;
estado?: EstadoPapeleria | 'sin_aprobacion'; estado?: EstadoPapeleria | 'sin_aprobacion';
entidadIds?: string[];
userRole?: string;
} }
export async function listPapeleria(pool: Pool, f: ListFilters): Promise<PapeleriaItem[]> { export async function listPapeleria(pool: Pool, f: ListFilters): Promise<PapeleriaItem[]> {
@@ -126,10 +143,17 @@ export async function listPapeleria(pool: Pool, f: ListFilters): Promise<Papeler
if (f.anio) { conds.push(`anio = $${i++}`); vals.push(f.anio); } if (f.anio) { conds.push(`anio = $${i++}`); vals.push(f.anio); }
if (f.mes) { conds.push(`mes = $${i++}`); vals.push(f.mes); } if (f.mes) { conds.push(`mes = $${i++}`); vals.push(f.mes); }
if (f.estado === 'sin_aprobacion') { if (f.estado === 'sin_aprobacion') {
conds.push('requiere_aprobacion = false'); conds.push('requiere_aprobacion = false AND requiere_aprobacion_cliente = false');
} else if (f.estado) { } else if (f.estado) {
conds.push(`estado = $${i++}`); vals.push(f.estado); conds.push(`estado = $${i++}`); vals.push(f.estado);
} }
if (f.entidadIds && f.entidadIds.length > 0) {
conds.push(`contribuyente_id = ANY($${i++})`);
vals.push(f.entidadIds);
}
if (f.userRole === 'cliente') {
conds.push('requiere_aprobacion_cliente = true');
}
const { rows } = await pool.query( const { rows } = await pool.query(
`SELECT ${SELECT} FROM papeleria_trabajo `SELECT ${SELECT} FROM papeleria_trabajo
WHERE ${conds.join(' AND ')} WHERE ${conds.join(' AND ')}
@@ -202,6 +226,39 @@ export async function rechazar(
return r ? ROW(r) : null; return r ? ROW(r) : null;
} }
export async function aprobarCliente(
pool: Pool,
id: number,
userId: string,
): Promise<PapeleriaItem | null> {
const { rows: [r] } = await pool.query(
`UPDATE papeleria_trabajo
SET estado_cliente = 'aprobado', aprobado_por_cliente = $2, aprobado_at_cliente = NOW(),
comentario_rechazo_cliente = NULL
WHERE id = $1 AND requiere_aprobacion_cliente = true
RETURNING ${SELECT}`,
[id, userId],
);
return r ? ROW(r) : null;
}
export async function rechazarCliente(
pool: Pool,
id: number,
userId: string,
comentario: string | null,
): Promise<PapeleriaItem | null> {
const { rows: [r] } = await pool.query(
`UPDATE papeleria_trabajo
SET estado_cliente = 'rechazado', aprobado_por_cliente = $2, aprobado_at_cliente = NOW(),
comentario_rechazo_cliente = $3
WHERE id = $1 AND requiere_aprobacion_cliente = true
RETURNING ${SELECT}`,
[id, userId, comentario],
);
return r ? ROW(r) : null;
}
export async function eliminar(pool: Pool, id: number): Promise<boolean> { export async function eliminar(pool: Pool, id: number): Promise<boolean> {
const { rowCount } = await pool.query( const { rowCount } = await pool.query(
`DELETE FROM papeleria_trabajo WHERE id = $1`, `DELETE FROM papeleria_trabajo WHERE id = $1`,
@@ -209,3 +266,30 @@ export async function eliminar(pool: Pool, id: number): Promise<boolean> {
); );
return (rowCount ?? 0) > 0; return (rowCount ?? 0) > 0;
} }
/**
* Calcula el estado visual combinado considerando ambas aprobaciones.
*/
export function estadoGlobal(item: PapeleriaItem): 'pendiente' | 'aprobado' | 'rechazado' | null {
const reqOwner = item.requiereAprobacion;
const reqCliente = item.requiereAprobacionCliente;
const estOwner = item.estado;
const estCliente = item.estadoCliente;
if (!reqOwner && !reqCliente) return null;
// Si cualquiera está rechazado, el documento está rechazado
if (estOwner === 'rechazado' || estCliente === 'rechazado') return 'rechazado';
// Si ambos requieren aprobación
if (reqOwner && reqCliente) {
if (estOwner === 'aprobado' && estCliente === 'aprobado') return 'aprobado';
return 'pendiente';
}
// Solo owner
if (reqOwner) return estOwner;
// Solo cliente
return estCliente;
}

View File

@@ -348,6 +348,17 @@ export async function emitInvoiceIfApplicable(paymentId: string): Promise<void>
data: { facturapiInvoiceId: invoice.id }, data: { facturapiInvoiceId: invoice.id },
}); });
// Enviar factura por email al cliente cuando se factura con datos reales
// (no público en general). Fail-soft: si el envío falla, no bloquea.
if (customer?.email) {
try {
await facturapiService.sendInvoiceByEmail(emitter.id, invoice.id, customer.email);
console.log(`[Invoicing] Factura ${invoice.id} enviada a ${customer.email}`);
} catch (emailErr: any) {
console.error(`[Invoicing] Error enviando factura ${invoice.id} a ${customer.email}:`, emailErr.message || emailErr);
}
}
auditLog({ auditLog({
tenantId: payment.tenantId, tenantId: payment.tenantId,
action: 'invoice.emitted_auto', action: 'invoice.emitted_auto',

View File

@@ -25,6 +25,9 @@ const preApprovalClient = new PreApproval(config);
const paymentClient = new MPPayment(config); const paymentClient = new MPPayment(config);
const preferenceClient = new Preference(config); const preferenceClient = new Preference(config);
/** Límite de la API legacy de preapproval de MercadoPago para MXN. */
export const MP_PREAPPROVAL_MAX_AMOUNT = 10000;
/** /**
* Fallback público para `back_url` cuando `FRONTEND_URL` apunta a localhost. * Fallback público para `back_url` cuando `FRONTEND_URL` apunta a localhost.
* MercadoPago rechaza URLs `http://localhost...` o cualquier dominio no * MercadoPago rechaza URLs `http://localhost...` o cualquier dominio no
@@ -227,6 +230,51 @@ export async function createProrationPreference(params: {
}; };
} }
/**
* Crea una Preference (checkout de pago único) para el pago anual de una
* suscripción. Se usa cuando el monto supera el límite de preapproval ($10k).
* external_reference = `subscription:{tenantId}:{subscriptionId}` para que el
* webhook active el período anual al aprobarse.
*/
export async function createSubscriptionPreference(params: {
tenantId: string;
subscriptionId: string;
plan: string;
amount: number;
payerEmail: string;
}): Promise<{ preferenceId: string; checkoutUrl: string }> {
if (!env.MP_ACCESS_TOKEN) {
throw new Error('MercadoPago no está configurado (falta MP_ACCESS_TOKEN en .env).');
}
const response = await preferenceClient.create({
body: {
items: [
{
id: `subscription-${params.subscriptionId}`,
title: `Horux360 - Plan ${params.plan} - Año completo`,
quantity: 1,
unit_price: params.amount,
currency_id: 'MXN',
},
],
payer: { email: resolvePayerEmail(params.payerEmail) },
external_reference: `subscription:${params.tenantId}:${params.subscriptionId}`,
back_urls: {
success: `${backUrlBase()}/configuracion/suscripcion?subscription=success`,
failure: `${backUrlBase()}/configuracion/suscripcion?subscription=failure`,
pending: `${backUrlBase()}/configuracion/suscripcion?subscription=pending`,
},
auto_return: 'approved',
},
});
return {
preferenceId: response.id!,
checkoutUrl: response.init_point!,
};
}
/** /**
* Crea una Preference (checkout de pago único) para comprar un paquete de * Crea una Preference (checkout de pago único) para comprar un paquete de
* timbres adicionales. external_reference = `timbres-pack:${paymentId}` para * timbres adicionales. external_reference = `timbres-pack:${paymentId}` para

View File

@@ -1,8 +1,9 @@
import { prisma } from '../../config/database.js'; import { prisma, tenantDb } from '../../config/database.js';
import * as mpService from './mercadopago.service.js'; import * as mpService from './mercadopago.service.js';
import { emailService } from '../email/email.service.js'; import { emailService } from '../email/email.service.js';
import { auditLog } from '../../utils/audit.js'; import { auditLog } from '../../utils/audit.js';
import { getTenantOwnerEmail } from '../../utils/memberships.js'; import { getTenantOwnerEmail, getTenantOwnerEmails } from '../../utils/memberships.js';
import { filterRecipientsByRole } from '../notification-preferences.service.js';
import { isDespachoPaidPlan, permiteOverage, type DespachoPricePhase } from '@horux/shared'; import { isDespachoPaidPlan, permiteOverage, type DespachoPricePhase } from '@horux/shared';
import { despachoPlanTieneDualidadDb, getPrecioDespachoDb } from '../plan-catalogo.service.js'; import { despachoPlanTieneDualidadDb, getPrecioDespachoDb } from '../plan-catalogo.service.js';
import { import {
@@ -243,25 +244,76 @@ export async function generatePaymentLink(tenantId: string) {
const ownerEmail = await getTenantOwnerEmail(tenantId); const ownerEmail = await getTenantOwnerEmail(tenantId);
if (!ownerEmail) throw new Error('No admin user found'); if (!ownerEmail) throw new Error('No admin user found');
const subscription = await getActiveSubscription(tenantId); let subscription = await getActiveSubscription(tenantId);
const plan = subscription?.plan || tenant.plan; const plan = (subscription?.plan || tenant.plan) as Plan;
const amount = subscription?.amount || 0; if (plan === 'custom' || plan === 'trial') {
throw new Error('No se puede generar link de pago para el plan actual');
}
if (!amount) throw new Error('No se encontró monto de suscripción'); const frequency = (subscription?.frequency as Frequency) || 'annual';
let amount = subscription?.amount ? Number(subscription.amount) : 0;
if (!amount) {
amount = await getPlanPrice(plan, frequency, 'firstYear');
}
// Los planes Business Control / Enterprise exceden el límite de cobro recurrente
// de MercadoPago ($10k). Para esos montos usamos una Preference de pago único
// anual; el webhook activa el período de 1 año al aprobarse.
if (amount > mpService.MP_PREAPPROVAL_MAX_AMOUNT) {
if (!subscription) {
subscription = await prisma.subscription.create({
data: {
tenantId,
plan: plan as any,
status: 'pending',
amount,
frequency,
},
});
invalidateSubscriptionCache(tenantId);
}
const mp = await mpService.createSubscriptionPreference({
tenantId,
subscriptionId: subscription.id,
plan,
amount,
payerEmail: ownerEmail,
});
await prisma.subscription.update({
where: { id: subscription.id },
data: { mpPreferenceId: mp.preferenceId, status: 'pending', amount },
});
return { paymentUrl: mp.checkoutUrl };
}
const mp = await mpService.createPreapproval({ const mp = await mpService.createPreapproval({
tenantId, tenantId,
reason: `Horux360 - Plan ${plan} - ${tenant.nombre}`, reason: `Horux360 - Plan ${plan} - ${tenant.nombre}`,
amount, amount,
payerEmail: ownerEmail, payerEmail: ownerEmail,
frequency,
}); });
// Update subscription with new MP preapproval ID
if (subscription) { if (subscription) {
await prisma.subscription.update({ await prisma.subscription.update({
where: { id: subscription.id }, where: { id: subscription.id },
data: { mpPreapprovalId: mp.preapprovalId }, data: { mpPreapprovalId: mp.preapprovalId, status: mp.status || 'pending' },
}); });
} else {
await prisma.subscription.create({
data: {
tenantId,
plan: plan as any,
status: mp.status || 'pending',
amount,
frequency,
mpPreapprovalId: mp.preapprovalId,
},
});
invalidateSubscriptionCache(tenantId);
} }
return { paymentUrl: mp.initPoint }; return { paymentUrl: mp.initPoint };
@@ -462,6 +514,54 @@ export async function subscribe(params: {
? `${tenant.nombre} - Plan ${params.plan} - $${amount.toLocaleString('es-MX')} primer año, $${renewalAmount.toLocaleString('es-MX')} renovaciones` ? `${tenant.nombre} - Plan ${params.plan} - $${amount.toLocaleString('es-MX')} primer año, $${renewalAmount.toLocaleString('es-MX')} renovaciones`
: `Horux360 - Plan ${params.plan} (${params.frequency}) - ${tenant.nombre}`; : `Horux360 - Plan ${params.plan} (${params.frequency}) - ${tenant.nombre}`;
// Planes Business Control / Enterprise superan el límite de cobro recurrente
// de MercadoPago ($10k). Se cobra el año completo vía Preference one-off; el
// webhook activa el período anual tras el primer pago aprobado.
if (amount > mpService.MP_PREAPPROVAL_MAX_AMOUNT) {
const subscription = await prisma.subscription.create({
data: {
tenantId: params.tenantId,
plan: params.plan,
status: 'pending',
amount,
frequency: params.frequency,
},
});
const mp = await mpService.createSubscriptionPreference({
tenantId: params.tenantId,
subscriptionId: subscription.id,
plan: params.plan,
amount,
payerEmail: params.payerEmail,
});
await prisma.subscription.update({
where: { id: subscription.id },
data: { mpPreferenceId: mp.preferenceId },
});
await prisma.subscription.updateMany({
where: { tenantId: params.tenantId, status: 'trial' },
data: { status: 'trial_converted' },
});
await prisma.tenant.update({
where: { id: params.tenantId },
data: { plan: params.plan },
});
invalidateSubscriptionCache(params.tenantId);
auditLog({
tenantId: params.tenantId,
action: 'subscription.created',
entityType: 'Subscription',
entityId: subscription.id,
metadata: { plan: params.plan, frequency: params.frequency, amount, paymentMethod: 'preference' },
});
return { subscription, paymentUrl: mp.checkoutUrl };
}
const mp = await mpService.createPreapproval({ const mp = await mpService.createPreapproval({
tenantId: params.tenantId, tenantId: params.tenantId,
reason, reason,
@@ -637,13 +737,20 @@ export async function applyApprovedUpgrade(subscriptionId: string): Promise<void
const newPlan = sub.upgradeTargetPlan as Plan; const newPlan = sub.upgradeTargetPlan as Plan;
const newAmount = Number(sub.upgradeTargetAmount); const newAmount = Number(sub.upgradeTargetAmount);
// Actualiza el monto del preapproval en MP (si existe) // Actualiza el monto del preapproval en MP (si existe). Si el nuevo monto
// supera el límite de cobro recurrente de MP ($10k), cancelamos el preapproval
// anterior: el plan alto se cobrará anualmente vía Preference one-off.
if (sub.mpPreapprovalId) { if (sub.mpPreapprovalId) {
try { if (newAmount > mpService.MP_PREAPPROVAL_MAX_AMOUNT) {
await mpService.updatePreapprovalAmount(sub.mpPreapprovalId, newAmount); await mpService.cancelPreapproval(sub.mpPreapprovalId);
} catch (error: any) { console.log(`[Upgrade] Preapproval ${sub.mpPreapprovalId} cancelado porque el nuevo monto $${newAmount} supera el límite de MP`);
console.error(`[Upgrade] Error actualizando preapproval ${sub.mpPreapprovalId}:`, error.message); } else {
throw error; // Re-lanza para que MP reintente el webhook try {
await mpService.updatePreapprovalAmount(sub.mpPreapprovalId, newAmount);
} catch (error: any) {
console.error(`[Upgrade] Error actualizando preapproval ${sub.mpPreapprovalId}:`, error.message);
throw error; // Re-lanza para que MP reintente el webhook
}
} }
} }
@@ -1085,7 +1192,7 @@ export async function sendExpiryReminders(): Promise<{ sent: number; resetOnly:
{ status: 'trial_expired', currentPeriodEnd: { gte: oneDayAgo } }, { status: 'trial_expired', currentPeriodEnd: { gte: oneDayAgo } },
], ],
}, },
include: { tenant: { select: { nombre: true, rfc: true } } }, include: { tenant: { select: { nombre: true, rfc: true, databaseName: true } } },
}); });
let sent = 0; let sent = 0;
@@ -1129,33 +1236,48 @@ export async function sendExpiryReminders(): Promise<{ sent: number; resetOnly:
// Hay algo que avisar. // Hay algo que avisar.
try { try {
const ownerEmail = await getTenantOwnerEmail(sub.tenantId); // Para suscripciones de pago, respeta preferencia 'subscription_expiring' del rol owner.
if (!ownerEmail) { // Para trials siempre avisa al owner (no depende de preferencias de notificación informativa).
const isTrialFlow = sub.status === 'trial' || sub.status === 'trial_expired';
let emailsToNotify: string[] = [];
if (isTrialFlow) {
const ownerEmail = await getTenantOwnerEmail(sub.tenantId);
if (ownerEmail) emailsToNotify = [ownerEmail];
} else {
const pool = await tenantDb.getPool(sub.tenantId, sub.tenant.databaseName);
const ownerEmails = await getTenantOwnerEmails(sub.tenantId);
const recipientsWithRole = ownerEmails.map(email => ({ email, role: 'owner' as const }));
emailsToNotify = await filterRecipientsByRole(pool, 'subscription_expiring', recipientsWithRole);
}
if (emailsToNotify.length === 0) {
skipped++; skipped++;
continue; continue;
} }
const isTrialFlow = sub.status === 'trial' || sub.status === 'trial_expired'; for (const ownerEmail of emailsToNotify) {
if (isTrialFlow) { if (isTrialFlow) {
if (bucket === 0) { if (bucket === 0) {
await emailService.sendTrialExpired(ownerEmail, { await emailService.sendTrialExpired(ownerEmail, {
nombre: sub.tenant.nombre, nombre: sub.tenant.nombre,
despachoNombre: sub.tenant.nombre, despachoNombre: sub.tenant.nombre,
}); });
} else {
await emailService.sendTrialReminder(ownerEmail, {
nombre: sub.tenant.nombre,
despachoNombre: sub.tenant.nombre,
diasRestantes: Math.max(0, daysUntil),
wizardCompleto: true,
});
}
} else { } else {
await emailService.sendTrialReminder(ownerEmail, { await emailService.sendSubscriptionExpiring(ownerEmail, {
nombre: sub.tenant.nombre, nombre: sub.tenant.nombre,
despachoNombre: sub.tenant.nombre, plan: sub.plan,
diasRestantes: Math.max(0, daysUntil), expiresAt: sub.currentPeriodEnd.toLocaleDateString('es-MX', { dateStyle: 'long' }),
wizardCompleto: true,
}); });
} }
} else {
await emailService.sendSubscriptionExpiring(ownerEmail, {
nombre: sub.tenant.nombre,
plan: sub.plan,
expiresAt: sub.currentPeriodEnd.toLocaleDateString('es-MX', { dateStyle: 'long' }),
});
} }
await prisma.subscription.update({ await prisma.subscription.update({

View File

@@ -45,7 +45,10 @@ export async function getRegimenesActivosClaves(tenantId: string): Promise<strin
/** /**
* Resuelve las claves de regímenes activos para la alerta de discrepancia. * Resuelve las claves de regímenes activos para la alerta de discrepancia.
* Si hay contribuyenteId, lee de contribuyentes.regimen_fiscal (comma-separated). * Si hay contribuyenteId, lee de contribuyentes.regimen_fiscal (comma-separated).
* Si no, fallback a TenantRegimenActivo (tabla central). * Si no, combina TenantRegimenActivo (tabla central) con los regímenes de
* todos los contribuyentes activos del tenant. Esto evita que la alerta
* aparezca en el correo por-contribuyente pero desaparezca en el dashboard
* cuando no hay un contribuyente seleccionado.
*/ */
export async function getRegimenesActivosClavesEfectivos( export async function getRegimenesActivosClavesEfectivos(
tenantId: string, tenantId: string,
@@ -61,9 +64,49 @@ export async function getRegimenesActivosClavesEfectivos(
if (rows.length > 0 && rows[0].regimen_fiscal) { if (rows.length > 0 && rows[0].regimen_fiscal) {
return rows[0].regimen_fiscal.split(',').map((c: string) => c.trim()).filter(Boolean); return rows[0].regimen_fiscal.split(',').map((c: string) => c.trim()).filter(Boolean);
} }
return []; // Fallback: si el contribuyente no tiene regimen_fiscal, usamos los del tenant
// para no perder la alerta si el campo quedó vacío accidentalmente.
const tenantRegimenes = await getRegimenesActivosClaves(tenantId);
if (tenantRegimenes.length > 0) return tenantRegimenes;
const { rows: allRows } = await pool.query(
`SELECT DISTINCT regimen_fiscal FROM contribuyentes WHERE regimen_fiscal IS NOT NULL AND regimen_fiscal <> ''`,
);
const set = new Set<string>();
for (const row of allRows) {
if (row.regimen_fiscal) {
for (const clave of row.regimen_fiscal.split(',')) {
const trimmed = clave.trim();
if (trimmed) set.add(trimmed);
}
}
}
return Array.from(set);
} }
return getRegimenesActivosClaves(tenantId);
const tenantRegimenes = await getRegimenesActivosClaves(tenantId);
// Fallback: si no hay regímenes configurados a nivel tenant, usamos los
// regímenes de todos los contribuyentes activos del tenant.
if (tenantRegimenes.length > 0) {
return tenantRegimenes;
}
const { rows } = await pool.query(
`SELECT DISTINCT regimen_fiscal FROM contribuyentes WHERE regimen_fiscal IS NOT NULL AND regimen_fiscal <> ''`,
);
const set = new Set<string>();
for (const row of rows) {
if (row.regimen_fiscal) {
for (const clave of row.regimen_fiscal.split(',')) {
const trimmed = clave.trim();
if (trimmed) set.add(trimmed);
}
}
}
return Array.from(set);
} }
export async function setRegimenesActivos(tenantId: string, regimenIds: number[]) { export async function setRegimenesActivos(tenantId: string, regimenIds: number[]) {

View File

@@ -94,12 +94,12 @@ export async function getFlujoEfectivo(
contribuyenteId?: string | null, contribuyenteId?: string | null,
): Promise<FlujoEfectivo> { ): Promise<FlujoEfectivo> {
const VIGENTE = `status NOT IN ('Cancelado', '0')`; const VIGENTE = `status NOT IN ('Cancelado', '0')`;
const RANGO = `fecha_emision >= $1::date AND fecha_emision < ($2::date + interval '1 day')`; const RANGO = `COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') >= $1::date AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') < ($2::date + interval '1 day')`;
const RANGO_PAGO = `fecha_pago_p >= $1::date AND fecha_pago_p < ($2::date + interval '1 day')`; const RANGO_PAGO = `(fecha_pago_p - interval '1 hour') >= $1::date AND (fecha_pago_p - interval '1 hour') < ($2::date + interval '1 day')`;
const { esEmisor, esReceptor } = await resolveEmisorReceptor(pool, contribuyenteId); const { esEmisor, esReceptor } = await resolveEmisorReceptor(pool, contribuyenteId);
const { rows: entradasPUE } = await pool.query(` const { rows: entradasPUE } = await pool.query(`
SELECT TO_CHAR(fecha_emision, 'YYYY-MM') as mes, COALESCE(SUM(total_mxn), 0) as total SELECT TO_CHAR(COALESCE(fecha_efectiva, fecha_emision - interval '1 hour'), 'YYYY-MM') as mes, COALESCE(SUM(total_mxn), 0) as total
FROM cfdis FROM cfdis
WHERE ${esEmisor} AND tipo_comprobante = 'I' AND metodo_pago = 'PUE' WHERE ${esEmisor} AND tipo_comprobante = 'I' AND metodo_pago = 'PUE'
AND ${VIGENTE} AND ${RANGO} AND ${VIGENTE} AND ${RANGO}
@@ -107,7 +107,7 @@ export async function getFlujoEfectivo(
`, [fechaInicio, fechaFin]); `, [fechaInicio, fechaFin]);
const { rows: entradasPago } = await pool.query(` const { rows: entradasPago } = await pool.query(`
SELECT TO_CHAR(fecha_pago_p, 'YYYY-MM') as mes, COALESCE(SUM(monto_pago_mxn), 0) as total SELECT TO_CHAR(fecha_pago_p - interval '1 hour', 'YYYY-MM') as mes, COALESCE(SUM(monto_pago_mxn), 0) as total
FROM cfdis FROM cfdis
WHERE ${esEmisor} AND tipo_comprobante = 'P' WHERE ${esEmisor} AND tipo_comprobante = 'P'
AND ${VIGENTE} AND ${RANGO_PAGO} AND ${VIGENTE} AND ${RANGO_PAGO}
@@ -115,7 +115,7 @@ export async function getFlujoEfectivo(
`, [fechaInicio, fechaFin]); `, [fechaInicio, fechaFin]);
const { rows: entradasNC } = await pool.query(` const { rows: entradasNC } = await pool.query(`
SELECT TO_CHAR(fecha_emision, 'YYYY-MM') as mes, COALESCE(SUM(total_mxn), 0) as total SELECT TO_CHAR(COALESCE(fecha_efectiva, fecha_emision - interval '1 hour'), 'YYYY-MM') as mes, COALESCE(SUM(total_mxn), 0) as total
FROM cfdis FROM cfdis
WHERE ${esEmisor} AND tipo_comprobante = 'E' AND metodo_pago = 'PUE' WHERE ${esEmisor} AND tipo_comprobante = 'E' AND metodo_pago = 'PUE'
AND COALESCE(cfdi_tipo_relacion, '') <> '07' AND COALESCE(cfdi_tipo_relacion, '') <> '07'
@@ -124,7 +124,7 @@ export async function getFlujoEfectivo(
`, [fechaInicio, fechaFin]); `, [fechaInicio, fechaFin]);
const { rows: salidasPUE } = await pool.query(` const { rows: salidasPUE } = await pool.query(`
SELECT TO_CHAR(fecha_emision, 'YYYY-MM') as mes, COALESCE(SUM(total_mxn), 0) as total SELECT TO_CHAR(COALESCE(fecha_efectiva, fecha_emision - interval '1 hour'), 'YYYY-MM') as mes, COALESCE(SUM(total_mxn), 0) as total
FROM cfdis FROM cfdis
WHERE ${esReceptor} AND tipo_comprobante = 'I' AND metodo_pago = 'PUE' WHERE ${esReceptor} AND tipo_comprobante = 'I' AND metodo_pago = 'PUE'
AND ${VIGENTE} AND ${RANGO} AND ${VIGENTE} AND ${RANGO}
@@ -132,7 +132,7 @@ export async function getFlujoEfectivo(
`, [fechaInicio, fechaFin]); `, [fechaInicio, fechaFin]);
const { rows: salidasPago } = await pool.query(` const { rows: salidasPago } = await pool.query(`
SELECT TO_CHAR(fecha_pago_p, 'YYYY-MM') as mes, COALESCE(SUM(monto_pago_mxn), 0) as total SELECT TO_CHAR(fecha_pago_p - interval '1 hour', 'YYYY-MM') as mes, COALESCE(SUM(monto_pago_mxn), 0) as total
FROM cfdis FROM cfdis
WHERE ${esReceptor} AND tipo_comprobante = 'P' WHERE ${esReceptor} AND tipo_comprobante = 'P'
AND ${VIGENTE} AND ${RANGO_PAGO} AND ${VIGENTE} AND ${RANGO_PAGO}
@@ -140,7 +140,7 @@ export async function getFlujoEfectivo(
`, [fechaInicio, fechaFin]); `, [fechaInicio, fechaFin]);
const { rows: salidasNC } = await pool.query(` const { rows: salidasNC } = await pool.query(`
SELECT TO_CHAR(fecha_emision, 'YYYY-MM') as mes, COALESCE(SUM(total_mxn), 0) as total SELECT TO_CHAR(COALESCE(fecha_efectiva, fecha_emision - interval '1 hour'), 'YYYY-MM') as mes, COALESCE(SUM(total_mxn), 0) as total
FROM cfdis FROM cfdis
WHERE ${esReceptor} AND tipo_comprobante = 'E' AND metodo_pago = 'PUE' WHERE ${esReceptor} AND tipo_comprobante = 'E' AND metodo_pago = 'PUE'
AND COALESCE(cfdi_tipo_relacion, '') <> '07' AND COALESCE(cfdi_tipo_relacion, '') <> '07'
@@ -187,8 +187,8 @@ async function calcularFlujoPorMes(pool: Pool, año: number, contribuyenteId?: s
const VIGENTE = `status NOT IN ('Cancelado', '0')`; const VIGENTE = `status NOT IN ('Cancelado', '0')`;
const fi = `${año}-01-01`; const fi = `${año}-01-01`;
const ff = `${año}-12-31`; const ff = `${año}-12-31`;
const RANGO = `fecha_emision >= $1::date AND fecha_emision < ($2::date + interval '1 day')`; const RANGO = `COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') >= $1::date AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') < ($2::date + interval '1 day')`;
const RANGO_PAGO = `fecha_pago_p >= $1::date AND fecha_pago_p < ($2::date + interval '1 day')`; const RANGO_PAGO = `(fecha_pago_p - interval '1 hour') >= $1::date AND (fecha_pago_p - interval '1 hour') < ($2::date + interval '1 day')`;
const { esEmisor, esReceptor } = await resolveEmisorReceptor(pool, contribuyenteId); const { esEmisor, esReceptor } = await resolveEmisorReceptor(pool, contribuyenteId);
const q = async (lado: 'EMITIDO' | 'RECIBIDO', tc: string, campo: string, mp?: string) => { const q = async (lado: 'EMITIDO' | 'RECIBIDO', tc: string, campo: string, mp?: string) => {
@@ -198,7 +198,7 @@ async function calcularFlujoPorMes(pool: Pool, año: number, contribuyenteId?: s
const noAnticipo = tc === 'E' ? `AND COALESCE(cfdi_tipo_relacion, '') <> '07'` : ''; const noAnticipo = tc === 'E' ? `AND COALESCE(cfdi_tipo_relacion, '') <> '07'` : '';
const ladoCond = lado === 'EMITIDO' ? esEmisor : esReceptor; const ladoCond = lado === 'EMITIDO' ? esEmisor : esReceptor;
const { rows } = await pool.query(` const { rows } = await pool.query(`
SELECT EXTRACT(MONTH FROM ${fechaCol})::int as mes, COALESCE(SUM(${campo}), 0) as total SELECT EXTRACT(MONTH FROM COALESCE(fecha_efectiva, ${fechaCol} - interval '1 hour'))::int as mes, COALESCE(SUM(${campo}), 0) as total
FROM cfdis FROM cfdis
WHERE ${ladoCond} AND tipo_comprobante = '${tc}' ${mpF} ${noAnticipo} AND ${VIGENTE} AND ${rango} WHERE ${ladoCond} AND tipo_comprobante = '${tc}' ${mpF} ${noAnticipo} AND ${VIGENTE} AND ${rango}
GROUP BY mes GROUP BY mes
@@ -277,7 +277,7 @@ export async function getConcentradoRfc(
COUNT(*)::int as "cantidadCfdis" COUNT(*)::int as "cantidadCfdis"
FROM cfdis FROM cfdis
WHERE ${esEmisor} AND tipo_comprobante = 'I' AND status NOT IN ('Cancelado', '0') WHERE ${esEmisor} AND tipo_comprobante = 'I' AND status NOT IN ('Cancelado', '0')
AND fecha_emision BETWEEN $1::date AND $2::date AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') BETWEEN $1::date AND $2::date
GROUP BY rfc_receptor, nombre_receptor GROUP BY rfc_receptor, nombre_receptor
ORDER BY "totalFacturado" DESC ORDER BY "totalFacturado" DESC
`, [fechaInicio, fechaFin]); `, [fechaInicio, fechaFin]);
@@ -298,7 +298,7 @@ export async function getConcentradoRfc(
COUNT(*)::int as "cantidadCfdis" COUNT(*)::int as "cantidadCfdis"
FROM cfdis FROM cfdis
WHERE ${esReceptor} AND tipo_comprobante = 'I' AND status NOT IN ('Cancelado', '0') WHERE ${esReceptor} AND tipo_comprobante = 'I' AND status NOT IN ('Cancelado', '0')
AND fecha_emision BETWEEN $1::date AND $2::date AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') BETWEEN $1::date AND $2::date
GROUP BY rfc_emisor, nombre_emisor GROUP BY rfc_emisor, nombre_emisor
ORDER BY "totalFacturado" DESC ORDER BY "totalFacturado" DESC
`, [fechaInicio, fechaFin]); `, [fechaInicio, fechaFin]);
@@ -338,8 +338,8 @@ export async function getCuentasXPagar(
FROM cfdis FROM cfdis
WHERE ${esReceptor} AND tipo_comprobante = 'I' AND metodo_pago = 'PPD' WHERE ${esReceptor} AND tipo_comprobante = 'I' AND metodo_pago = 'PPD'
AND status NOT IN ('Cancelado', '0') AND status NOT IN ('Cancelado', '0')
AND fecha_emision >= $1::date AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') >= $1::date
AND fecha_emision < ($2::date + interval '1 day') AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') < ($2::date + interval '1 day')
AND COALESCE(saldo_pendiente_mxn, total_mxn) > 0.01 AND COALESCE(saldo_pendiente_mxn, total_mxn) > 0.01
${regimenFilter} ${regimenFilter}
GROUP BY rfc_emisor, nombre_emisor GROUP BY rfc_emisor, nombre_emisor
@@ -365,7 +365,7 @@ export async function getCuentasXPagar(
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
const VIGENTE_ER = `status NOT IN ('Cancelado', '0')`; const VIGENTE_ER = `status NOT IN ('Cancelado', '0')`;
const RANGO_FECHA = `fecha_emision >= $1::date AND fecha_emision < ($2::date + interval '1 day')`; const RANGO_FECHA = `COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') >= $1::date AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') < ($2::date + interval '1 day')`;
const BASE_MONTO = `COALESCE(subtotal_mxn, 0) - COALESCE(descuento_mxn, 0)`; const BASE_MONTO = `COALESCE(subtotal_mxn, 0) - COALESCE(descuento_mxn, 0)`;
function sameDateLastYear(dateStr: string): string { function sameDateLastYear(dateStr: string): string {
@@ -842,8 +842,8 @@ export async function getCuentasXCobrar(
FROM cfdis FROM cfdis
WHERE ${esEmisor} AND tipo_comprobante = 'I' AND metodo_pago = 'PPD' WHERE ${esEmisor} AND tipo_comprobante = 'I' AND metodo_pago = 'PPD'
AND status NOT IN ('Cancelado', '0') AND status NOT IN ('Cancelado', '0')
AND fecha_emision >= $1::date AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') >= $1::date
AND fecha_emision < ($2::date + interval '1 day') AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') < ($2::date + interval '1 day')
AND COALESCE(saldo_pendiente_mxn, total_mxn) > 0.01 AND COALESCE(saldo_pendiente_mxn, total_mxn) > 0.01
${regimenFilter} ${regimenFilter}
GROUP BY rfc_receptor, nombre_receptor GROUP BY rfc_receptor, nombre_receptor

View File

@@ -72,9 +72,17 @@ export async function querySat(
requestType: 'metadata' | 'cfdi' = 'cfdi' requestType: 'metadata' | 'cfdi' = 'cfdi'
): Promise<QueryResult> { ): Promise<QueryResult> {
try { try {
// El SAT rechaza fechaInicial >= fechaFinal. Como formatDateForSat trunca
// a medianoche, dos fechas dentro del mismo día calendario resultan iguales.
// Ajustamos fechaFin al día siguiente para evitar el error.
let adjustedFechaFin = fechaFin;
if (formatDateForSat(fechaInicio) === formatDateForSat(fechaFin)) {
adjustedFechaFin = new Date(fechaFin.getTime() + 24 * 60 * 60 * 1000);
}
const period = DateTimePeriod.createFromValues( const period = DateTimePeriod.createFromValues(
formatDateForSat(fechaInicio), formatDateForSat(fechaInicio),
formatDateForSat(fechaFin) formatDateForSat(adjustedFechaFin)
); );
const downloadType = new DownloadType(tipo === 'emitidos' ? 'issued' : 'received'); const downloadType = new DownloadType(tipo === 'emitidos' ? 'issued' : 'received');
@@ -239,10 +247,11 @@ export async function downloadSatPackage(
} }
/** /**
* Formatea una fecha para el SAT (YYYY-MM-DD HH:mm:ss) * Formatea una fecha para el SAT (YYYY-MM-DD HH:mm:ss).
* El SAT requiere hora 00:00:00; cualquier otra hora causa
* "Fecha final invalida" / "Fecha inicial invalida".
*/ */
function formatDateForSat(date: Date): string { function formatDateForSat(date: Date): string {
const pad = (n: number) => n.toString().padStart(2, '0'); const pad = (n: number) => n.toString().padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ` + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} 00:00:00`;
`${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
} }

View File

@@ -30,20 +30,20 @@ export async function loginSatCsf(
const publicPage = await context.newPage(); const publicPage = await context.newPage();
publicPage.setDefaultTimeout(60_000); publicPage.setDefaultTimeout(60_000);
await publicPage.goto(PUBLIC_URL, { waitUntil: 'networkidle' }); await publicPage.goto(PUBLIC_URL, { waitUntil: 'networkidle', timeout: 120_000 });
await publicPage.waitForTimeout(2000); await publicPage.waitForTimeout(3000);
// Click acordeón "Obtén tu constancia" / "Obtener constancia" // Click acordeón "Obtén tu constancia" / "Obtener constancia"
const obtenerLocator = publicPage.locator( const obtenerLocator = publicPage.locator(
'text=/Obt[eé]n\\s+la\\s+constancia|Obt[eé]n\\s+tu\\s+constancia|Obtener\\s+constancia|Obtener\\s+la\\s+constancia/i', 'text=/Obt[eé]n\\s+la\\s+constancia|Obt[eé]n\\s+tu\\s+constancia|Obtener\\s+constancia|Obtener\\s+la\\s+constancia/i',
).first(); ).first();
await obtenerLocator.waitFor({ state: 'visible', timeout: 60_000 }); await obtenerLocator.waitFor({ state: 'visible', timeout: 120_000 });
await obtenerLocator.scrollIntoViewIfNeeded(); await obtenerLocator.scrollIntoViewIfNeeded();
await obtenerLocator.click(); await obtenerLocator.click();
await publicPage.waitForTimeout(1500); await publicPage.waitForTimeout(1500);
// Click "SERVICIO" → popup // Click "SERVICIO" → popup
const popupPromise = context.waitForEvent('page', { timeout: 60_000 }); const popupPromise = context.waitForEvent('page', { timeout: 120_000 });
await publicPage.locator('text=/^\\s*SERVICIO\\s*$/i').first().click(); await publicPage.locator('text=/^\\s*SERVICIO\\s*$/i').first().click();
const loginPage = await popupPromise; const loginPage = await popupPromise;
await loginPage.waitForLoadState('domcontentloaded'); await loginPage.waitForLoadState('domcontentloaded');
@@ -56,7 +56,7 @@ export async function loginSatCsf(
const efirmaBtn = loginPage const efirmaBtn = loginPage
.locator('button:has-text("e.firma"):not(:has-text("portable")), input[type="button"][value="e.firma" i], input[type="submit"][value="e.firma" i]') .locator('button:has-text("e.firma"):not(:has-text("portable")), input[type="button"][value="e.firma" i], input[type="submit"][value="e.firma" i]')
.first(); .first();
await efirmaBtn.waitFor({ state: 'visible', timeout: 30_000 }); await efirmaBtn.waitFor({ state: 'visible', timeout: 60_000 });
await efirmaBtn.scrollIntoViewIfNeeded(); await efirmaBtn.scrollIntoViewIfNeeded();
await efirmaBtn.click(); await efirmaBtn.click();
@@ -82,7 +82,7 @@ export async function loginSatCsf(
return rfc !== null && rfc.value.length >= 12; return rfc !== null && rfc.value.length >= 12;
}, },
null, null,
{ timeout: 60_000 }, { timeout: 120_000 },
); );
rfcPopulated = true; rfcPopulated = true;
} catch { } catch {
@@ -121,7 +121,7 @@ export async function loginSatCsf(
// Esperar a que salga del dominio de login y aterrice en el portal SAT // Esperar a que salga del dominio de login y aterrice en el portal SAT
await loginPage.waitForURL( await loginPage.waitForURL(
url => url.toString().includes('wwwmat.sat.gob.mx/operacion/'), url => url.toString().includes('wwwmat.sat.gob.mx/operacion/'),
{ timeout: 60_000 }, { timeout: 120_000 },
); );
await loginPage.waitForLoadState('networkidle').catch(() => undefined); await loginPage.waitForLoadState('networkidle').catch(() => undefined);
await loginPage.waitForTimeout(2000); await loginPage.waitForTimeout(2000);

View File

@@ -69,6 +69,11 @@ interface CfdiParsed {
cfdisRelacionados: string | null; cfdisRelacionados: string | null;
conceptos: ConceptoParsed[]; conceptos: ConceptoParsed[];
xmlOriginal: string; xmlOriginal: string;
// Factura global (InformacionGlobal)
periodicidad: string | null;
mesesGlobal: string | null;
añoGlobal: string | null;
} }
interface ConceptoParsed { interface ConceptoParsed {
@@ -569,6 +574,9 @@ export function parseXml(xmlContent: string, downloadType: 'emitidos' | 'recibid
...nominaData, ...nominaData,
conceptos: extractConceptos(comprobante), conceptos: extractConceptos(comprobante),
xmlOriginal: xmlContent, xmlOriginal: xmlContent,
periodicidad: comprobante.InformacionGlobal?.['@_Periodicidad'] || null,
mesesGlobal: comprobante.InformacionGlobal?.['@_Meses'] || null,
añoGlobal: comprobante.InformacionGlobal?.['@_Año'] || null,
}; };
if (!cfdi.uuid) { if (!cfdi.uuid) {

View File

@@ -18,7 +18,7 @@ import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
const POLL_INTERVAL_MS = 60000; // 60 segundos const POLL_INTERVAL_MS = 60000; // 60 segundos
const MAX_POLL_ATTEMPTS = 45; // 45 minutos máximo (45 × 60s) const MAX_POLL_ATTEMPTS = 500; // ~8 horas máximo para syncs iniciales grandes
const YEARS_TO_SYNC = 6; // SAT solo permite descargar últimos 6 años const YEARS_TO_SYNC = 6; // SAT solo permite descargar últimos 6 años
/** /**
@@ -121,6 +121,35 @@ async function getOrCreateRfc(pool: Pool, rfc: string, razonSocial: string | nul
return rows[0].id; return rows[0].id;
} }
/**
* Calcula la fecha efectiva de un CFDI para métricas.
* Si tiene InformacionGlobal, usa el año/mes declarado.
* Para bimestral (periodicidad 05), convierte el código 13-18 a mes 2-12.
*/
function calcFechaEfectiva(cfdi: CfdiParsed): Date | null {
if (!cfdi.añoGlobal || !cfdi.mesesGlobal) {
return null;
}
const anio = parseInt(cfdi.añoGlobal, 10);
if (isNaN(anio)) return null;
const mesesStr = cfdi.mesesGlobal;
const mesesParts = mesesStr.split(',').map((s: string) => s.trim());
const ultimoMesStr = mesesParts[mesesParts.length - 1];
let mes = parseInt(ultimoMesStr, 10);
if (isNaN(mes)) return null;
// Bimestral: códigos 13-18 → meses 2,4,6,8,10,12
if (cfdi.periodicidad === '05') {
if (mes >= 13 && mes <= 18) {
mes = (mes - 12) * 2;
}
}
if (mes < 1 || mes > 12) return null;
return new Date(anio, mes - 1, 1);
}
/** /**
* Guarda los XMLs extraídos del ZIP en disco para respaldo * Guarda los XMLs extraídos del ZIP en disco para respaldo
*/ */
@@ -212,6 +241,10 @@ async function saveCfdis(
cfdi.subsidioCausado, m(cfdi.subsidioCausado), cfdi.subsidioCausado, m(cfdi.subsidioCausado),
cfdi.regimenFiscalEmisor, cfdi.regimenFiscalReceptor, cfdi.regimenFiscalEmisor, cfdi.regimenFiscalReceptor,
cfdi.codigoPostalReceptor, cfdi.codigoPostalReceptor,
cfdi.periodicidad,
cfdi.mesesGlobal,
cfdi.añoGlobal,
calcFechaEfectiva(cfdi),
cfdi.xmlOriginal, cfdi.xmlOriginal,
cfdi.cfdiTipoRelacion, cfdi.cfdisRelacionados, cfdi.cfdiTipoRelacion, cfdi.cfdisRelacionados,
jobId, jobId,
@@ -261,16 +294,17 @@ async function saveCfdis(
subsidio_causado=$78, subsidio_causado_mxn=$79, subsidio_causado=$78, subsidio_causado_mxn=$79,
regimen_fiscal_emisor=$80, regimen_fiscal_receptor=$81, regimen_fiscal_emisor=$80, regimen_fiscal_receptor=$81,
codigo_postal_receptor=$82, codigo_postal_receptor=$82,
xml_original=$83, periodicidad=$83, meses_global=$84, año_global=$85, fecha_efectiva=$86,
cfdi_tipo_relacion=$84, cfdis_relacionados=$85, xml_original=$87,
last_sat_sync=NOW(), sat_sync_job_id=$86::uuid, cfdi_tipo_relacion=$88, cfdis_relacionados=$89,
last_sat_sync=NOW(), sat_sync_job_id=$90::uuid,
actualizado_en=NOW() actualizado_en=NOW()
WHERE uuid = $1`, WHERE LOWER(uuid) = LOWER($1)`,
[cfdi.uuid, ...vals] [cfdi.uuid, ...vals]
); );
// Re-insert conceptos for updated CFDI // Re-insert conceptos for updated CFDI
await pool.query(`DELETE FROM cfdi_conceptos WHERE cfdi_id = $1`, [existing[0].id]); await pool.query(`DELETE FROM cfdi_conceptos WHERE cfdi_id = $1`, [existing[0].id]);
await saveConceptos(pool, existing[0].id, cfdi); await saveConceptosWithRetry(pool, existing[0].id, cfdi);
updated++; updated++;
} else { } else {
// $1-$83 = data fields (year..cfdis_relacionados), $84 = jobId, $85 = contribuyente_id // $1-$83 = data fields (year..cfdis_relacionados), $84 = jobId, $85 = contribuyente_id
@@ -310,6 +344,7 @@ async function saveCfdis(
subsidio_causado, subsidio_causado_mxn, subsidio_causado, subsidio_causado_mxn,
regimen_fiscal_emisor, regimen_fiscal_receptor, regimen_fiscal_emisor, regimen_fiscal_receptor,
codigo_postal_receptor, codigo_postal_receptor,
periodicidad, meses_global, año_global, fecha_efectiva,
xml_original, xml_original,
cfdi_tipo_relacion, cfdis_relacionados, cfdi_tipo_relacion, cfdis_relacionados,
source, sat_sync_job_id, last_sat_sync, contribuyente_id source, sat_sync_job_id, last_sat_sync, contribuyente_id
@@ -320,8 +355,8 @@ async function saveCfdis(
[...vals, contribuyenteId] [...vals, contribuyenteId]
); );
// Get the inserted cfdi id and save conceptos // Get the inserted cfdi id and save conceptos
const { rows: [newRow] } = await pool.query(`SELECT id FROM cfdis WHERE uuid = $1`, [cfdi.uuid]); const { rows: [newRow] } = await pool.query(`SELECT id FROM cfdis WHERE LOWER(uuid) = LOWER($1)`, [cfdi.uuid]);
if (newRow) await saveConceptos(pool, newRow.id, cfdi); if (newRow) await saveConceptosWithRetry(pool, newRow.id, cfdi);
inserted++; inserted++;
} }
// Marcar el mes para recompute de métricas pre-calculadas. Para tipo P // Marcar el mes para recompute de métricas pre-calculadas. Para tipo P
@@ -404,6 +439,26 @@ async function saveConceptos(pool: Pool, cfdiId: number, cfdi: CfdiParsed): Prom
} }
} }
/** Reintenta saveConceptos con backoff exponencial para tolerar errores transitorios. */
async function saveConceptosWithRetry(pool: Pool, cfdiId: number, cfdi: CfdiParsed, maxRetries = 3): Promise<void> {
let lastError: any;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
await saveConceptos(pool, cfdiId, cfdi);
return;
} catch (err: any) {
lastError = err;
if (attempt < maxRetries) {
const delay = 500 * attempt;
console.warn(`[SAT] saveConceptos falló (intento ${attempt}/${maxRetries}) para CFDI ${cfdi.uuid}, reintentando en ${delay}ms...`);
await new Promise(r => setTimeout(r, delay));
}
}
}
console.error(`[SAT] saveConceptos falló definitivamente después de ${maxRetries} intentos para CFDI ${cfdi.uuid}:`, lastError?.message || lastError);
throw lastError;
}
/** /**
* Guarda/actualiza CFDIs desde metadata del SAT. * Guarda/actualiza CFDIs desde metadata del SAT.
* - Si el CFDI no existe: inserta con datos básicos de metadata (sin XML). * - Si el CFDI no existe: inserta con datos básicos de metadata (sin XML).
@@ -554,30 +609,35 @@ async function requestAndDownload(
}); });
let existingMap = (jobRow?.satRequestIds as Record<string, string> | null) || {}; let existingMap = (jobRow?.satRequestIds as Record<string, string> | null) || {};
// NOTA: se desactivó la reutilización de requestIds de jobs previos porque el SAT
// limita las descargas por solicitud. Reusar un requestId de un job anterior puede
// agotar el límite y devolver "Máximo de descargas permitidas", dejando el recovery
// sin poder descargar. Cada job nuevo crea sus propias solicitudes.
//
// Si no existe en el job actual, buscar en el job más reciente del mismo tenant/contribuyente // Si no existe en el job actual, buscar en el job más reciente del mismo tenant/contribuyente
// SOLO si el rango de fechas es idéntico (mismo dateFrom/dateTo). // SOLO si el rango de fechas es idéntico (mismo dateFrom/dateTo).
if (!existingMap[kindKey]) { // if (!existingMap[kindKey]) {
const previousJob = await prisma.satSyncJob.findFirst({ // const previousJob = await prisma.satSyncJob.findFirst({
where: { // where: {
tenantId: jobRow?.tenantId, // tenantId: jobRow?.tenantId,
contribuyenteId: jobRow?.contribuyenteId ?? null, // contribuyenteId: jobRow?.contribuyenteId ?? null,
id: { not: jobId }, // id: { not: jobId },
dateFrom: jobRow?.dateFrom, // dateFrom: jobRow?.dateFrom,
dateTo: jobRow?.dateTo, // dateTo: jobRow?.dateTo,
}, // },
orderBy: { createdAt: 'desc' }, // orderBy: { createdAt: 'desc' },
select: { satRequestIds: true }, // select: { satRequestIds: true },
}); // });
if (previousJob?.satRequestIds) { // if (previousJob?.satRequestIds) {
const prevMap = previousJob.satRequestIds as Record<string, string>; // const prevMap = previousJob.satRequestIds as Record<string, string>;
if (prevMap[kindKey]) { // if (prevMap[kindKey]) {
console.log(`[SAT] Reutilizando requestId de job previo (${label}): ${prevMap[kindKey]}`); // console.log(`[SAT] Reutilizando requestId de job previo (${label}): ${prevMap[kindKey]}`);
// Copiar al job actual para futuros usos // // Copiar al job actual para futuros usos
await persistSatRequestId(jobId, kindKey, prevMap[kindKey]); // await persistSatRequestId(jobId, kindKey, prevMap[kindKey]);
existingMap = { ...existingMap, [kindKey]: prevMap[kindKey] }; // existingMap = { ...existingMap, [kindKey]: prevMap[kindKey] };
} // }
} // }
} // }
let requestId: string | null = existingMap[kindKey] || null; let requestId: string | null = existingMap[kindKey] || null;
let verifyResult: Awaited<ReturnType<typeof verifySatRequest>> | undefined; let verifyResult: Awaited<ReturnType<typeof verifySatRequest>> | undefined;
@@ -770,6 +830,26 @@ async function determineChunkMonths(
fechaInicio: Date, fechaInicio: Date,
fechaFin: Date, fechaFin: Date,
): Promise<number> { ): Promise<number> {
// Si el job previo del mismo tenant/contribuyente ya tenía chunks,
// inferimos que el volumen es alto y usamos 6 meses directamente
// para evitar el sondeo lento del SAT.
const previousJob = await prisma.satSyncJob.findFirst({
where: {
tenantId: ctx.tenantId,
contribuyenteId: ctx.contribuyenteId ?? null,
id: { not: jobId },
status: 'completed',
cfdisFound: { gt: 0 },
},
orderBy: { createdAt: 'desc' },
select: { satRequestIds: true, cfdisFound: true },
});
if (previousJob?.satRequestIds && Object.keys(previousJob.satRequestIds as Record<string, string>).length > 0) {
const chunkMonths = (previousJob.cfdisFound || 0) > 15_000 ? 3 : 6;
console.log(`[SAT] Reutilizando estrategia de job previo (${previousJob.cfdisFound} CFDIs) → bloques de ${chunkMonths} meses`);
return chunkMonths;
}
const THRESHOLD = 15_000; const THRESHOLD = 15_000;
let totalCfdis = 0; let totalCfdis = 0;

View File

@@ -14,10 +14,10 @@ export interface SweepResult {
} }
const DEFAULT_RUNNING_HOURS_BY_TYPE: Record<string, number> = { const DEFAULT_RUNNING_HOURS_BY_TYPE: Record<string, number> = {
initial: 8, initial: 24,
daily: 4, daily: 4,
incremental: 2, incremental: 2,
custom: 4, custom: 24,
}; };
/** /**

View File

@@ -17,6 +17,8 @@ export interface TareaCatalogo {
active: boolean; active: boolean;
orden: number; orden: number;
createdAt: Date; createdAt: Date;
auxiliarAsignadoId?: string | null;
auxiliarAsignadoNombre?: string | null;
} }
export interface TareaPeriodo { export interface TareaPeriodo {
@@ -47,6 +49,8 @@ const ROW_TO_TAREA = (r: any): TareaCatalogo => ({
active: r.active, active: r.active,
orden: r.orden, orden: r.orden,
createdAt: r.created_at, createdAt: r.created_at,
auxiliarAsignadoId: r.auxiliarAsignadoId ?? null,
auxiliarAsignadoNombre: r.auxiliarAsignadoNombre ?? null,
}); });
const ROW_TO_PERIODO = (r: any): TareaPeriodo => ({ const ROW_TO_PERIODO = (r: any): TareaPeriodo => ({
@@ -68,9 +72,13 @@ function sanitizeUuid(id: string): string {
export async function listTareas(pool: Pool, contribuyenteId: string): Promise<TareaCatalogo[]> { export async function listTareas(pool: Pool, contribuyenteId: string): Promise<TareaCatalogo[]> {
const { rows } = await pool.query( const { rows } = await pool.query(
`SELECT * FROM tareas_catalogo `SELECT
WHERE contribuyente_id = $1 AND active = true tc.*,
ORDER BY orden, nombre`, ta.auxiliar_user_id AS "auxiliarAsignadoId"
FROM tareas_catalogo tc
LEFT JOIN tarea_asignaciones ta ON ta.tarea_id = tc.id
WHERE tc.contribuyente_id = $1 AND tc.active = true
ORDER BY tc.orden, tc.nombre`,
[sanitizeUuid(contribuyenteId)], [sanitizeUuid(contribuyenteId)],
); );
return rows.map(ROW_TO_TAREA); return rows.map(ROW_TO_TAREA);
@@ -272,6 +280,59 @@ export async function listTareasConPeriodoActual(
return tareas.map(t => ({ ...t, periodoActual: periodos.get(t.id) ?? null })); return tareas.map(t => ({ ...t, periodoActual: periodos.get(t.id) ?? null }));
} }
export interface TareaConContribuyente extends TareaConPeriodo {
contribuyenteRfc: string;
contribuyenteRazonSocial: string;
}
/**
* Lee tareas activas con periodo actual para una lista de contribuyentes.
* Útil para la vista "Mis Tareas".
*/
export async function listTareasConPeriodoPorContribuyentes(
pool: Pool,
contribuyenteIds: string[],
): Promise<TareaConContribuyente[]> {
if (contribuyenteIds.length === 0) return [];
// Materializar periodos para cada contribuyente en paralelo
await Promise.all(contribuyenteIds.map(id => materializarPeriodos(pool, id)));
const { rows: tareasRows } = await pool.query(
`SELECT tc.*, c.entidad_id AS "contribuyenteId",
c.rfc AS "contribuyenteRfc",
COALESCE(r.razon_social, c.rfc) AS "contribuyenteRazonSocial"
FROM tareas_catalogo tc
JOIN contribuyentes c ON c.entidad_id = tc.contribuyente_id
LEFT JOIN rfcs r ON UPPER(r.rfc) = UPPER(c.rfc)
WHERE tc.contribuyente_id = ANY($1::uuid[]) AND tc.active = true
ORDER BY c.rfc, tc.orden, tc.nombre`,
[contribuyenteIds],
);
if (tareasRows.length === 0) return [];
const tareaIds = tareasRows.map((r: any) => r.id);
const today = new Date().toISOString().split('T')[0];
const { rows: periodoRows } = await pool.query(
`SELECT DISTINCT ON (tarea_id) *
FROM tarea_periodos
WHERE tarea_id = ANY($1::uuid[])
AND (completada = false OR fecha_limite >= $2::date)
ORDER BY tarea_id, fecha_limite ASC`,
[tareaIds, today],
);
const periodos = new Map(periodoRows.map((r: any) => [r.tarea_id, ROW_TO_PERIODO(r)]));
return tareasRows.map((r: any) => ({
...ROW_TO_TAREA(r),
contribuyenteId: r.contribuyenteId,
contribuyenteRfc: r.contribuyenteRfc,
contribuyenteRazonSocial: r.contribuyenteRazonSocial,
periodoActual: periodos.get(r.id) ?? null,
}));
}
// ─── Completar / descompletar periodo ─── // ─── Completar / descompletar periodo ───
const ROLES_SUPERVISOR = new Set(['owner', 'cfo', 'supervisor']); const ROLES_SUPERVISOR = new Set(['owner', 'cfo', 'supervisor']);

View File

@@ -91,26 +91,41 @@ export async function createTenant(data: {
} }
}); });
// 3. Create admin user with temp password // 3. Create or reuse admin user
const tempPassword = randomBytes(4).toString('hex'); // 8-char random
const hashedPassword = await bcrypt.hash(tempPassword, 10);
// Get owner role ID from roles table (rol que asignamos al dueño del tenant al crearlo)
const ownerRol = await prisma.rol.findUnique({ where: { nombre: 'owner' } }); const ownerRol = await prisma.rol.findUnique({ where: { nombre: 'owner' } });
if (!ownerRol) throw new Error('Rol owner no encontrado en la base de datos'); if (!ownerRol) throw new Error('Rol owner no encontrado en la base de datos');
const user = await prisma.user.create({ let user = await prisma.user.findUnique({ where: { email: data.adminEmail } });
data: { let tempPassword: string | null = null;
email: data.adminEmail,
passwordHash: hashedPassword,
nombre: data.adminNombre,
lastTenantId: tenant.id,
},
});
// Crea membership owner del nuevo user en su tenant (fase 4 multi-tenant) if (!user) {
await prisma.tenantMembership.create({ tempPassword = randomBytes(4).toString('hex'); // 8-char random
data: { const hashedPassword = await bcrypt.hash(tempPassword, 10);
user = await prisma.user.create({
data: {
email: data.adminEmail,
passwordHash: hashedPassword,
nombre: data.adminNombre,
lastTenantId: tenant.id,
},
});
} else {
// User ya existe: actualizar lastTenantId y nombre si cambió
await prisma.user.update({
where: { id: user.id },
data: {
lastTenantId: tenant.id,
...(data.adminNombre && data.adminNombre !== user.nombre ? { nombre: data.adminNombre } : {}),
},
});
}
// Crea membership owner del user en su tenant (fase 4 multi-tenant).
// Si ya existía (re-invite a otro tenant), reactivar.
await prisma.tenantMembership.upsert({
where: { userId_tenantId: { userId: user.id, tenantId: tenant.id } },
update: { rolId: ownerRol.id, isOwner: true, active: true },
create: {
userId: user.id, userId: user.id,
tenantId: tenant.id, tenantId: tenant.id,
rolId: ownerRol.id, rolId: ownerRol.id,

View File

@@ -2,6 +2,7 @@ import { prisma } from '../config/database.js';
import bcrypt from 'bcryptjs'; import bcrypt from 'bcryptjs';
import { randomBytes } from 'crypto'; import { randomBytes } from 'crypto';
import { getDespachoPlanLimits } from './plan-catalogo.service.js'; import { getDespachoPlanLimits } from './plan-catalogo.service.js';
import { emailService } from './email/email.service.js';
import type { UserListItem, UserInvite, UserUpdate, Role } from '@horux/shared'; import type { UserListItem, UserInvite, UserUpdate, Role } from '@horux/shared';
/** /**
@@ -99,6 +100,13 @@ export async function inviteUsuario(tenantId: string, data: UserInvite): Promise
lastTenantId: tenantId, lastTenantId: tenantId,
}, },
}); });
// Enviar correo de bienvenida con credenciales (non-blocking)
emailService.sendWelcome(data.email, {
nombre: data.nombre,
email: data.email,
tempPassword,
}).catch(err => console.error('[EMAIL] Welcome email failed:', err));
} }
const rolId = await getRolId(data.role); const rolId = await getRolId(data.role);
@@ -200,6 +208,73 @@ export async function getAllUsuarios(): Promise<UserListItem[]> {
return memberships.map(m => mapMembershipRow(m, true)); return memberships.map(m => mapMembershipRow(m, true));
} }
export async function createUsuarioGlobal(
tenantId: string,
data: UserInvite
): Promise<UserListItem> {
const tenant = await prisma.tenant.findUnique({
where: { id: tenantId },
select: { plan: true },
});
// Límite del catálogo despacho desde BD (con cache). -1 = ilimitado.
const planLimits = tenant ? await getDespachoPlanLimits(tenant.plan) : null;
const maxUsers = planLimits?.maxUsers ?? 1;
const currentCount = await prisma.tenantMembership.count({
where: { tenantId, active: true },
});
if (maxUsers !== -1 && currentCount >= maxUsers) {
throw new Error('Límite de usuarios alcanzado para este plan');
}
// Si el email ya existe como user global, agregamos membership en este tenant
let user = await prisma.user.findUnique({ where: { email: data.email } });
let tempPassword: string | null = null;
if (!user) {
tempPassword = randomBytes(4).toString('hex');
const passwordHash = await bcrypt.hash(tempPassword, 12);
user = await prisma.user.create({
data: {
email: data.email,
passwordHash,
nombre: data.nombre,
lastTenantId: tenantId,
},
});
// Enviar correo de bienvenida con credenciales (non-blocking)
emailService.sendWelcome(data.email, {
nombre: data.nombre,
email: data.email,
tempPassword,
}).catch(err => console.error('[EMAIL] Welcome email failed:', err));
}
const rolId = await getRolId(data.role);
await prisma.tenantMembership.upsert({
where: { userId_tenantId: { userId: user.id, tenantId } },
update: { rolId, isOwner: false, active: true },
create: {
userId: user.id,
tenantId,
rolId,
isOwner: false,
active: true,
},
});
const membership = await prisma.tenantMembership.findUnique({
where: { userId_tenantId: { userId: user.id, tenantId } },
include: MEMBERSHIP_INCLUDE,
});
return mapMembershipRow(membership!);
}
export async function updateUsuarioGlobal( export async function updateUsuarioGlobal(
userId: string, userId: string,
data: UserUpdate & { tenantId?: string } data: UserUpdate & { tenantId?: string }

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