20 KiB
Sesión 2026-04-25 — Módulo Despacho, Tareas y Papelería
Sesión enfocada en features nuevos del fork Horux_despacho. Los fixes
fiscales del día anterior están en
docs/plans/2026-04-24-session-fixes-and-features.md.
Índice
- Banner "CSD recién tramitado"
- Preferencias de notificación por contribuyente
- Tareas operativas (recurrentes)
- Papelería de Trabajo
- Módulo "Despacho" — 3 páginas
- Selector de periodo global
- Asignación de supervisor desde /usuarios
- Ajustes de UI
- Migraciones aplicadas
- Pendientes
1. Banner "CSD recién tramitado"
Problema
El SAT tarda 24-72h en propagar un CSD nuevo a la Lista de Contribuyentes Obligados (LCO). Durante esa ventana, intentos de emisión vía Facturapi fallan con mensajes tipo "RFC no encontrado en LCO". El user pierde tiempo intentando emitir y abre tickets innecesarios.
Solución
Banner contextual en /facturacion que aparece SOLO si hubo un rechazo
del SAT con patrón LCO en las últimas 24h.
Archivos
- Migración 033:
facturapi_orgs.last_lco_rejection_at timestamptz. apps/api/src/controllers/facturacion.controller.ts:- Helper
isLcoRejection(message)con regex contra patrones SAT. - Helper
markLcoRejection(pool, contribuyenteId)upsertea timestamp. - En el catch de
emitir()se dispara la marca cuando aplica. - Endpoint
getLcoStatusretorna{ hasRecentLcoRejection, rejectedAt }.
- Helper
apps/api/src/routes/facturacion.routes.ts:GET /facturacion/lco-status.apps/web/app/(dashboard)/facturacion/page.tsx: banner amber al inicio del form, polling cada 15min.
Mensaje
CSD aún en proceso de validación — espera 24 horas antes de emitir tu factura.
Auto-expiración
La condición es rejectedAt > NOW() - 24h. Pasadas las 24h el banner
desaparece sin necesidad de cron.
2. Preferencias de notificación por contribuyente
Problema
Los emails informativos se enviaban siempre. El user quería poder desactivar tipos específicos por contribuyente sin afectar a otros.
Solución
JSONB email_preferences en contribuyentes; los handlers de envío
respetan la preferencia antes de mandar.
Archivos
- Migración 034:
contribuyentes.email_preferences jsonb DEFAULT '{}'. apps/api/src/services/notification-preferences.service.ts:EMAIL_TYPES = ['documento_subido', 'weekly_update', 'subscription_expiring', 'recordatorio_fiscal'].getContribuyenteEmailPreferences()con default true.setContribuyenteEmailPreferences()merge sobre JSONB.
apps/api/src/services/notify-upload.service.ts: checkprefs.documento_subidoantes de enviar.apps/api/src/controllers/notification-preferences.controller.ts:listPreferences+updatePreferences.apps/api/src/routes/notification-preferences.routes.ts:GET / PUT /api/notificaciones.apps/web/app/(dashboard)/configuracion/notificaciones/page.tsx: toggles por tipo de email por contribuyente, optimistic update.
Tipos de email
| Tipo | Estado | Notas |
|---|---|---|
documento_subido |
✅ activo | check en notify-upload.service.ts |
weekly_update |
⏳ próximamente | el job es tenant-wide, requiere refactor |
subscription_expiring |
⏳ próximamente | no es per-contribuyente hoy |
recordatorio_fiscal |
⏳ placeholder | para futuras alertas fiscales |
Los toggles "Próximamente" se persisten pero no bloquean el envío todavía. Cuando se implemente cada email per-contribuyente, basta con agregar el check.
Default
Todo activado. Si la columna está vacía o le falta una key, asumimos
true para preservar comportamiento previo.
3. Tareas operativas (recurrentes)
Sistema completo de tareas operativas del despacho por contribuyente, distinto del flujo de obligaciones fiscales (que ya existía).
Características
- Recurrencias: semanal, quincenal, mensual, bimestral, trimestral, semestral, anual.
- Día de vencimiento: día de la semana (1-7) o día del mes (1-31) según recurrencia.
- Estados: pendiente / completada (en BD); "atrasada" se calcula en frontend.
- Permisos:
- Rol
cliente: bloqueado en TODOS los endpoints (403). - Flag
solo_supervisor_completa: si está en true, solo owner/cfo/supervisor pueden marcarla completa.
- Rol
Archivos
- Migración 035:
tareas_catalogo (id, contribuyente_id, nombre, descripcion, recurrencia, dia_semana, dia_mes, solo_supervisor_completa, es_default, active, orden, created_at) tarea_periodos (id, tarea_id, periodo, fecha_limite, completada, completada_at, completada_por, notas, created_at) apps/api/src/services/tareas.service.ts:- CRUD del catálogo.
- Materialización lazy: cuando el frontend pide tareas con periodo
actual, el backend genera periodos faltantes solo del presente en
adelante (códigos
2025-W12,2025-01,2025-B1,2025-Q1,2025-S1,2025). seedTareasDefault()con 4 tareas: estados de cuenta (día 5), conciliación (día 10), contabilización (día 14), revisión fiscal preliminar (día 15,solo_supervisor_completa=true).getEventosTareasParaCalendario()retorna shapeEventoFiscalcontipo: 'tarea'.contarTareasProximasVencer()para alertas auto.getAuxiliarUserIdDeContribuyente()resuelve cartera → auxiliar.
apps/api/src/controllers/tareas.controller.ts:- Bloqueo rol cliente.
notifyAuxiliarTareaCompletada(): dispara email cuando una tarea consolo_supervisor_completa=truese marca completa por supervisor/owner — el auxiliar recibe aviso.
apps/api/src/routes/tareas.routes.ts:GET /api/tareas — list con periodos materializados POST /api/tareas — crear PATCH /api/tareas/:id — update DELETE /api/tareas/:id — soft delete (active=false) POST /api/tareas/seed — seed defaults POST /api/tareas/periodo/:id/completar DELETE /api/tareas/periodo/:id/completarapps/api/src/services/email/templates/tarea-completada.ts: template para notificar al auxiliar.apps/api/src/services/alertas-auto.service.ts:alertaTareasProximasVencer()(≤3 días, prioridad media), enchufada engenerarAlertasAutomaticas.apps/api/src/controllers/calendario.controller.ts: incluye tareas en el GET de eventos cuando hay contribuyente seleccionado y rol no es cliente.packages/shared/src/types/calendario.ts: agregado'tarea'aTipoEvento.
Frontend
apps/web/components/obligaciones/tareas-tab.tsx: pestaña "Tareas" en/configuracion/obligacionescon tabla, modal crear/editar, check pendiente/completada.apps/web/app/(dashboard)/configuracion/obligaciones/page.tsx: tabs "Obligaciones" / "Tareas".
Flujo email "tarea revisada"
- Auxiliar trabaja en algo del contribuyente.
- Supervisor/owner marca como completada la tarea
solo_supervisor_completa=true(ej. "Revisión fiscal preliminar"). - Backend resuelve
auxiliar_user_iddesde la cartera del contribuyente. - Email al auxiliar con el detalle (quién marcó, periodo, notas).
4. Papelería de Trabajo
Sección nueva en /documentos para subir archivos de trabajo del
despacho con flujo opcional de aprobación.
Características
- No accesible para usuarios rol cliente (oculta + 403 en API).
- Formatos permitidos: PDF, Word (doc/docx), Excel (xls/xlsx).
- Tamaño máximo: 5 MB por archivo (validado backend + frontend).
- Periodo: mes + año (selector en el modal de subida).
- Aprobación opcional: checkbox al subir; si está activo, queda en
estado
pendientey solo owner/supervisor pueden aprobar/rechazar. - Comentario opcional al rechazar.
Archivos
- Migración 036:
Estados válidos:
papeleria_trabajo (id, contribuyente_id, nombre, descripcion, archivo bytea, archivo_filename, archivo_mime, archivo_size, anio, mes, requiere_aprobacion, estado, aprobado_por, aprobado_at, comentario_rechazo, subido_por, created_at)pendiente | aprobado | rechazado. NULL si no requiere aprobación. apps/api/src/services/papeleria.service.ts:- Validación MIME + tamaño.
- CRUD:
uploadPapeleria,listPapeleria(filtros año/mes/estado),downloadArchivo,aprobar,rechazar,eliminar. aprobar/rechazarvalidan rol owner/cfo/supervisor.
apps/api/src/controllers/papeleria.controller.ts:- Bloqueo rol cliente con
rejectClienteRole(). - Schema Zod estricto.
- Notificaciones:
- Al subir con
requiere_aprobacion=true: email a owners + supervisores. - Al aprobar/rechazar: email al uploader (excepto si es el mismo).
- Al subir con
- Excepción al uploader: si el aprobador es el mismo user que subió (ej. owner que sube su propia papelería), no se auto-notifica.
- Bloqueo rol cliente con
apps/api/src/services/email/templates/papeleria.ts: 2 templates (papeleriaAprobacionRequeridaEmail+papeleriaDecisionEmail).apps/api/src/routes/papeleria.routes.ts:GET /api/papeleria — list con filtros POST /api/papeleria — upload (base64 en JSON body) GET /api/papeleria/:id/download — descarga binaria POST /api/papeleria/:id/aprobar POST /api/papeleria/:id/rechazar — body: { comentario? } DELETE /api/papeleria/:id
Frontend
apps/web/components/documentos/papeleria-tab.tsx: pestaña con filtros (año, mes, estado), modal de upload con base64 conversion, badges de estado, modal de rechazo con comentario.apps/web/app/(dashboard)/documentos/page.tsx: pestaña condicionalif (user.role !== 'cliente').
5. Módulo "Despacho" — 3 páginas
Reemplazo del módulo /pendientes con sub-secciones específicas por
rol. Sidebar renombrado: "Pendientes" → "Despacho".
Estructura
/despachos → redirect según rol
/despachos/contribuyentes → owner/cfo (métricas globales)
/despachos/mis-asignados → owner/cfo/supervisor/auxiliar
/despachos/equipo → owner/cfo/supervisor (jerárquico)
Sub-nav común
apps/web/components/despachos/despacho-subnav.tsx: tabs visibles según rol del user (filtrado en frontend).defaultDespachoPathForRole(role): helper para el redirect en/despachos.
5a. /despachos/contribuyentes (owner-only)
7 cards de métricas globales del despacho, todas filtrables por periodo:
| Card | Fuente | Notas |
|---|---|---|
| Total contribuyentes | entidades_gestionadas activos |
independiente del periodo |
| Última extracción SAT | MAX(sat_sync_jobs.completed_at) BD central |
independiente del periodo |
| Progreso del mes (%) | obligaciones+tareas completadas / total |
barra de color |
| Declaraciones presentadas | declaraciones_provisionales con created_at en periodo |
|
| Declaraciones pagadas | subset con pdf_pago IS NOT NULL |
|
| Declaraciones atrasadas | obligaciones de declaración pendientes con periodo < seleccionado |
heurística por categoría |
| Tareas atrasadas | tareas pendientes con fecha_limite < primer día |
Heurística "es declaración" (sin flag explícito en el catálogo):
LOWER(categoria) ~ 'mensual|anual|declarac' OR LOWER(nombre) LIKE '%declarac%'
Si crece la base se puede agregar columna es_declaracion boolean y
ajustar.
5b. /despachos/mis-asignados
Tabla con contribuyentes filtrada por cartera del user:
| Rol | Filtro |
|---|---|
| owner / cfo | TODOS los contribuyentes |
| supervisor | contribuyentes en sus carteras top-level + subcarteras |
| auxiliar | contribuyentes en carteras donde es auxiliar_user_id |
Columnas:
- Contribuyente (link →
/configuracion/obligacionescon contribuyente preseleccionado). - Cartera.
- Avance (barra de color verde≥80% / ámbar 50-79% / rojo <50% + porcentaje numérico).
- Atrasos — badge agregado de obligaciones+tareas atrasadas; "Al día" si no tiene.
- Obl. periodo (completadas / pendientes).
- Tareas periodo (completadas / pendientes).
Resaltado de filas con atrasos: bg-red-50/50 para llamar la
atención.
Orden: por suma de atrasos descendente (rezagados arriba).
5c. /despachos/equipo
Vista jerárquica supervisor → auxiliares. Click en un supervisor expande debajo a sus auxiliares.
Resolución de la relación supervisor → auxiliar (UNION de 3 fuentes):
1. carteras top-level con auxiliar_user_id + supervisor_user_id directos
2. subcarteras con auxiliar_user_id; supervisor del cartera padre
3. tabla legacy auxiliar_supervisores (fallback / override desde /usuarios)
Sección "Auxiliares sin supervisor asignado": si después del UNION
queda un auxiliar activo (rol auxiliar en tenant_memberships) sin
supervisor, aparece en una sección al final con icono de advertencia
ámbar — solo el owner la ve.
Permisos:
- owner/cfo: ve TODOS los supervisores con sus auxiliares + huérfanos.
- supervisor: ve solo a sí mismo con sus auxiliares (sin huérfanos).
Métricas por miembro:
- Contribuyentes asignados.
- Avance del periodo (barra de color, igual que mis-asignados).
- Atrasos (obligaciones + tareas).
Backend
apps/api/src/services/despacho-stats.service.ts:getContribuyentesStats(pool, tenantId, año?, mes?).getMisAsignados(pool, userId, userRole, año?, mes?).getEquipoStats(pool, userId, userRole, tenantId, año?, mes?).
apps/api/src/controllers/despacho-stats.controller.ts: 3 endpoints con guards de rol.apps/api/src/routes/despacho-stats.routes.ts: mountea en/api/despachos.
6. Selector de periodo global
Problema
Antes solo el mes en curso era visible. El user quiere navegar a otros periodos para revisar avance histórico.
Solución
Wrapper <PeriodoSelector /> alrededor del <PeriodSelector /> de
@horux/shared-ui (mismo patrón visual que /dashboard, /impuestos,
/reportes). Persiste selección en localStorage via Zustand.
Archivos
apps/web/stores/periodo-store.ts:fechaInicio/fechaFin(strings ISO YYYY-MM-DD).- Default: primer y último día del mes en curso.
- Helper
añoMesFromFechaInicio()para queries que usan año/mes.
apps/web/components/periodo-selector.tsx: wrapper que se pasa como children al<Header>en cada página/despachos/*.
Endpoints que aceptan el filtro
GET /api/despachos/contribuyentes-stats?año=YYYY&mes=MMGET /api/despachos/mis-asignados?año=YYYY&mes=MMGET /api/despachos/equipo-stats?año=YYYY&mes=MM
Default: mes en curso si no vienen los params.
7. Asignación de supervisor desde /usuarios
Problema
Al invitar un auxiliar se pedía supervisor, pero no había forma de
cambiarlo después. Al abrir el modal aparecía "none" porque el
endpoint solo leía de auxiliar_supervisores ignorando carteras.
Solución
Endpoint que consulta TODAS las fuentes de la relación supervisor↔auxiliar
y un modal "Supervisor" en /usuarios.
Archivos backend
apps/api/src/controllers/usuarios.controller.ts:getSupervisor: UNION priorizada —auxiliar_supervisores(1) → cartera directa (2) → cartera padre de subcartera (3). El primer resultado gana.updateSupervisor: upsert enauxiliar_supervisores(override explícito sobre la cartera). PasarsupervisorUserId: nullborra la asignación.
apps/api/src/routes/usuarios.routes.ts:GET /api/usuarios/:id/supervisor PUT /api/usuarios/:id/supervisor
Frontend
- Botón "Supervisor" (icono
UserCheck) en cada fila de auxiliar, visible solo en tenant tipo despacho y para admins. - Modal con
<Select>de supervisores + opción "Sin supervisor asignado".
Trade-off
La tabla auxiliar_supervisores actúa como override sobre la
cartera. Si después de asignar via cartera, el owner cambia el
supervisor desde /usuarios, queda guardado en auxiliar_supervisores
y eso prevalece. Si quieres revertir, borrar la fila desde
/usuarios (opción "Sin supervisor asignado").
8. Ajustes de UI
8a. Sidebar sin selector de contribuyente
El <ContribuyenteSelector /> ya estaba en el header — duplicado en
sidebar agregaba ruido. Removido del sidebar (apps/web/components/layouts/sidebar.tsx).
8b. Selector contribuyente oculto en /despachos/*
Las 3 vistas de Despacho son cross-contribuyente (métricas agregadas, mis asignados es la lista completa, equipo es jerarquía). Filtrar por contribuyente individual rompe el propósito.
apps/web/components/contribuyente-selector.tsx: array HIDDEN_PATHS
con /despachos. Si el pathname matchea, retorna null antes de
renderizar.
8c. Header
Selector de periodo se pasa como children del <Header> (al lado
del título), igual que en /dashboard — no en el lado derecho.
8d. PageNotificaciones centrada
<main className="... mx-auto"> agregado.
8e. Configuración Obligaciones Fiscales — Header
Faltaba el <Header /> en la página /configuracion/obligaciones así
que perdía el selector global. Agregado.
9. Migraciones aplicadas
| # | Archivo | Tabla / cambio |
|---|---|---|
| 033 | 033_facturapi_orgs_lco_rejection.sql |
facturapi_orgs.last_lco_rejection_at |
| 034 | 034_contribuyentes_email_preferences.sql |
contribuyentes.email_preferences jsonb |
| 035 | 035_tareas.sql |
tareas_catalogo + tarea_periodos |
| 036 | 036_papeleria_trabajo.sql |
papeleria_trabajo |
Aplicadas vía pnpm db:migrate-tenants en ambos tenants
(DESPACHO_MO7JE8BZ_VDOPR y DESPACHO_MO3NI6U8_B9VGG).
10. Pendientes
Tareas
- Soporte de tipo "one-time" (no recurrente). Hoy todas son recurrentes.
- Asignación explícita de tarea a un user específico (hoy se asume cartera).
Notificaciones
- Implementar el envío per-contribuyente para
weekly_update,subscription_expiring,recordatorio_fiscal(los 3 toggles "Próximamente"). Requiere refactor del cron de weekly-update para que itere por contribuyente en lugar de por tenant.
Métricas Despacho
- "Es declaración" en obligaciones: agregar flag explícito al catálogo
(
obligaciones_contribuyente.es_declaracion boolean) y migrar la heurística actual de regex. - Drill-down: click en cada card del módulo Contribuyentes lleva a una vista detallada (ej. listado de declaraciones atrasadas con contribuyente y periodo).
Equipo
- Mostrar % avance histórico (gráfica) de cada miembro a lo largo de varios periodos.
- Asignar manualmente contribuyentes a un user desde la vista de equipo (hoy solo via carteras).
Papelería
- Versionado: si el user sube un nuevo archivo del mismo "concepto", guardar histórico de versiones (hoy crea fila independiente cada vez).
- Filtros por uploader.
Bugs reportados (por revisar)
- Visibilidad de auxiliares en carteras de supervisores: en la pantalla de carteras, el supervisor está viendo a auxiliares que NO están asignados a él. Debe filtrar para que cada supervisor solo vea los auxiliares en sus propias carteras (top-level + subcarteras donde es supervisor).
- Tareas completadas > pendientes en módulo Despacho: las métricas muestran más tareas completadas de las que existen pendientes — aritméticamente raro. Posible causa: el conteo de "completadas" incluye periodos históricos mientras que "pendientes" se limita al periodo seleccionado. Validar que ambos lados del ratio usen el mismo universo (mes filtrado) o documentar explícitamente la asimetría si es intencional.
- Drill-down de alerta TipoRelacion sospechoso incompleto: no
están saliendo todos los CFDIs tipo E con posible TipoRelacion
errónea en el listado. Revisar la heurística en
apps/api/src/services/alertas-auto.service.ts(constanteSOSPECHOSA_TIPO_RELACION_WHEREy reuso engetCfdisTipoRelacionSospechosadel controller). Posibles causas: (a) operador&&de PostgreSQL sobrestring_to_arrayno detecta algunos casos por case-sensitivity o caracteres extraños encfdis_relacionados; (b) el filtro excluye E concfdi_tipo_relacion=NULLcuando algunas inconsistencias podrían tener ese estado; (c) RFC del contribuyente no se aplica uniformemente en el JOIN.