Files
HoruxDespachosNuevo/docs/plans/2026-04-23-features-fixes-y-derivados.md

15 KiB

Sesión 2026-04-23 — Cierre de features pending + derivados técnicos

Sesión extensa que cerró 4 features pending (#6 #7 #9 + investigación #10) y 3 derivados técnicos (#3 watchdog cron, #4 validación CSD, #5 logging SAT), además de 7 bugfixes descubiertos en el proceso.

1. Feature #6 — Enlazar obligaciones ↔ declaraciones (trazabilidad)

El backend del matching impuesto→obligación ya existía (completarObligacionesPorDeclaracion en declaraciones.service.ts hacía el UPSERT en obligacion_periodos). Faltaba la trazabilidad reversa: poder clickear una obligación completada y ver la declaración que la cubrió.

Migration 030 obligacion_periodos.declaracion_id INT REFERENCES declaraciones_provisionales(id) ON DELETE SET NULL + índice parcial.

Backend:

  • completarObligacionesPorDeclaracion ahora acepta declaracionId y lo persiste en el INSERT/UPDATE del ON CONFLICT.
  • createDeclaracion pasa declaracion.id tras el INSERT.
  • getObligacionesPorPeriodo hace LEFT JOIN declaraciones_provisionales y devuelve un objeto declaracion: DeclaracionLink | null por cada periodo completado (nuevo tipo exportado).

Frontend (/pendientes/page.tsx):

  • ObligacionPeriodo extendido con declaracion: DeclaracionLink | null.
  • Link ↗ Declaración MM/YYYY [Compl.] junto a obligaciones completadas con FK. Click abre PDF en nueva pestaña vía /documentos/declaraciones/:id/pdf/declaracion.

Semántica: ON DELETE SET NULL — el periodo sigue marcado completado si la declaración se borra, pero pierde la referencia. El usuario decide si reabrirlo.


2. Feature #7 — Calendario con colores por status

Backend + frontend ya estaban implementados end-to-end (tipos obligacion-pendiente|completada|atrasada generados desde obligacion_periodos, colores amber/green/red mapeados). Solo agregué 2 mejoras UX:

  • AlertTriangle icon para obligacion-atrasada (antes usaba el mismo Clock que pendiente).
  • Leyenda de colores visible en el header del grid del calendario: amber/green/red + violet (recordatorio custom).

3. Feature #10 — Alertas obligaciones per-contribuyente (investigado)

El bug reportado "las alertas manuales muestran más obligaciones de las que tiene el contribuyente" no se reproduce al 2026-04-23.

Auditoría SQL sobre los despachos activos:

  • 0 alertas ob-* con obligacion_id inexistente (huérfanas)
  • 0 alertas para obligaciones activa=false
  • 0 alertas para periodos ya completados
  • Count per-contribuyente: alertas ≤ obligaciones activas en todos los casos

Protecciones verificadas en código:

  1. removeObligacion hace soft-delete + DELETE alertas + DELETE periodos
  2. inactiveFilter en getAlertasManualesPendientes excluye por activa=false
  3. contribuyenteFilter strict previene leak cross-contribuyente
  4. sincronizarDesdeObligacionesContribuyente solo genera current + previous month

Probablemente fue arreglado implícitamente al agregar el cleanup en removeObligacion en una sesión anterior. Si reaparece el síntoma, hay que capturar el drift específico antes de tocar código.


4. Feature #9 — Emails al subir declaración / documento extra

Destinatarios: owners activos del despacho + supervisor del contribuyente (entidades_gestionadas.supervisor_user_id), excluyendo al uploader mismo.

Archivos:

  • services/email/templates/documento-subido.tsnuevo, template parametrizable por kind: 'declaracion' | 'extra', con bloques condicionales para periodo/impuestos/monto (declaración) o nombre/categoría/descripción (extra). HTML escapado para evitar XSS.
  • services/email/email.service.ts:sendDocumentoSubido(recipients, data) — loop por recipient con try/catch individual.
  • utils/memberships.ts:
    • getTenantOwnerEmails(tenantId)nuevo, lista todos los owners.
    • getUserEmailById(userId)nuevo, resolver supervisor.
  • services/notify-upload.service.ts:notifyDocumentoSubido(...)nuevo orquestador: resuelve recipients, dedupea, excluye uploader, envía.
  • controllers/documentos.controller.ts:
    • crearDeclaracion y crearExtra disparan notify con .catch() (fire-and-forget).

Dev mode: sin SMTP configurado, transport de @horux/core loguea a consola.


5. Bugfixes descubiertos en el camino

5.1 feature-gate.middleware.ts crash con planes despacho

hasFeature(plan) asumía PLANS[plan] siempre existe. Con planes business_cloud/ business_control (que viven en DESPACHO_PLANS, no PLANS) → undefined.features → crash del API. Fix en 2 capas:

  • Middleware ahora detecta plan despacho y rutea a hasDespachoFeature.
  • shared/constants/plans.ts:hasFeature defensive con ?. + ?? false.

5.2 Declaraciones sin filtro por contribuyente

declaraciones_provisionales no tenía columna contribuyente_id; todas las declaraciones se mezclaban entre RFCs de un despacho.

Migration 031 agrega contribuyente_id UUID NULL con ON DELETE SET NULL. Reemplaza el UNIQUE (año, mes) WHERE tipo='normal' por (año, mes, contribuyente_id) WHERE tipo='normal' para que cada RFC tenga su propia normal mensual.

Backend + controller + API client + hook + UI actualizados para pasar contribuyenteId end-to-end. Declaraciones legacy quedan con NULL (interpretadas como "tenant-wide / legacy") — invisibles cuando se filtra por un RFC específico.

5.3 completada_por UUID/email mismatch

obligacion_periodos.completada_por es UUID, pero createDeclaracion pasaba el email del usuario. Crash silencioso con invalid input syntax for type uuid: "jd@demo.com".

Separé en 2 campos:

  • creadoPor: string (email) — va a declaraciones_provisionales.creado_por VARCHAR.
  • creadoPorUserId: string (UUID) — va a obligacion_periodos.completada_por UUID.

5.4 Alertas de pago desaparecían al subir declaración normal con monto>0

La llamada a completarObligacionesPorDeclaracion se hacía SIEMPRE que hubiera contribuyenteId, incluyendo declaraciones normales cuyo pago aún está pendiente. Esto cerraba tanto alertas decl-* como ob-* (per-obligación), dejando al usuario sin visibilidad del pago pendiente.

Fix: completarObligacionesPorDeclaracion solo se llama si la declaración cubre el pago (tipo='complementaria' || montoPago=0). Además, uploadComprobantePago ahora también llama completarObligacionesPorDeclaracion para cerrar las obligaciones cuando se sube el comprobante de pago.

5.5 Keyword matching IVA → DIOT y mensual → anual

Subir una declaración con impuestos: ['IVA'] cerraba también obligaciones "Declaración de proveedores de IVA" (DIOT) y "Declaración anual de ISR" porque la sustring "iva"/"isr" matchaba demasiado amplio.

Fix doble:

  1. IMPUESTO_A_OBLIGACION_KEYWORDS ahora tiene include + exclude:
    • IVA: include=['iva'], exclude=['diot','proveedores de iva','informativa']
    • ISR: include=['isr'], exclude=['retenciones','asimilados a salarios']
  2. Filtro por periodicidadobligacion.frecuencia: una declaración mensual no cierra obligaciones anuales.

Datos revertidos en BD: 3 obligaciones que se habían marcado completadas por error (Husberto DIOT, Husberto anual ISR, Horux 360 anual ISR).

5.6 Drill-down: falta tipo E + columna Monto Pago

Los drill-downs desde dashboard/impuestos pasaban tipoComprobante: 'I,P' — excluían tipo E (notas de crédito), que sí entran en los cálculos de ingresos (NC recibida) y gastos (NC emitida).

Fix:

  • Nuevo param bucket en GET /cfdi/drill-down:
    • bucket=ingresos(EMIT I PUE) + (EMIT P) + (RECIB E PUE)
    • bucket=gastos(RECIB I PUE) + (RECIB P) + (EMIT E PUE)
    • Aliases causado/acreditable
  • 6 links en dashboard/impuestos migrados de type+tipoComprobante+metodoPago a bucket.
  • Nueva columna "Monto Pago" en la tabla drill-down — sortable, solo muestra valor para tipo P. Excel export incluye la columna.

6. Derivado #3 — Cron watchdog SAT

Refactoricé la lógica del script CLI scripts/sweep-stale-sat-jobs.ts a función exportable en services/sat/sweep-stale-jobs.service.ts:sweepStaleSatJobs.

El CLI ahora es un thin wrapper que reusa la función. El cron WATCHDOG_CRON_SCHEDULE = '0 */2 * * *' en sat-sync.job.ts llama sweepStaleSatJobs({ apply: true }) cada 2 horas. Thresholds vía env: STALE_PENDING_HOURS (default 12h) y STALE_RUNNING_HOURS (default 4h).

Gate del cron: usa el mismo cronsEnabled del index.ts (prod, o ENABLE_CRONS_IN_DEV=1).


7. Derivado #4 — Validación CSD↔RFC en uploadCsdContribuyente

Antes: el contador subía un CSD, Facturapi devolvía "Certificado no válido" sin decir por qué. Podría ser RFC mismatch, FIEL subida en lugar de CSD, o cert vencido.

Ahora uploadCsdContribuyente valida antes de subir a Facturapi usando @nodecfdi/credentials:

  1. Parseable + password correcta
  2. !credential.isFiel() — debe ser CSD, no FIEL
  3. certificate.rfc() === contribuyente.rfc (uppercase, strict)
  4. validToDateTime() > now — no vencido

Cada fallo tiene un mensaje específico. Facturapi sigue validando también; nuestra capa es defense-in-depth con diagnóstico mejor.


8. Derivado #5 — Investigación SAT rejections (logging mejorado)

La librería @nodecfdi/sat-ws-descarga-masiva expone verifyResult.getCodeRequest() con los 5 códigos SAT específicos del estado de la solicitud:

  • 5000 Accepted — solicitud recibida con éxito
  • 5002 Exhausted — agotadas solicitudes de por vida (mismos parámetros)
  • 5003 MaximumLimit — tope máximo de CFDI/Metadata
  • 5004 EmptyResult — no hay información en el rango
  • 5005 Duplicated — solicitud duplicada (existe una vigente con mismos params)

Antes verifySatRequest solo loggeaba getStatus().getCode() que es el HTTP-wrapper status (siempre 5000 "Aceptada" si la llamada HTTP funciona). Ahora también captura codeRequest.getValue() + getEntryId() + getMessage() en el debug log y en el error message cuando status es rejected/failed.

Patrón observado en Manuel (sin datos del nuevo log): 9 rejections de emitidos en bloques 3-9 + 12-13, pero 10-11 funcionaron. NO es rate-limit constante. Hipótesis más probable: 5005 Duplicated — solicitudes previas stale del SAT que interfieren con nuevas.

Acción pendiente: capturar un caso nuevo con el log mejorado para confirmar y decidir estrategia (limpiar solicitudes previas, esperar 72h entre intentos, reducir rangos, etc.).


Archivos tocados

Backend

  • apps/api/src/migrations/tenant/030_obligacion_periodos_declaracion_id.sqlnuevo (#6)
  • apps/api/src/migrations/tenant/031_declaraciones_contribuyente_id.sqlnuevo (fix 5.2)
  • apps/api/src/services/declaraciones.service.tscompletarObligacionesPorDeclaracion con declaracionId + periodicidad filter + include/exclude keywords; createDeclaracion con creadoPorUserId; listDeclaraciones con contribuyenteId filter; uploadComprobantePago llama completar obligaciones
  • apps/api/src/services/obligaciones.service.tsgetObligacionesPorPeriodo con LEFT JOIN declaraciones + tipo DeclaracionLink
  • apps/api/src/controllers/documentos.controller.tslistarDeclaraciones lee contribuyenteId; crearDeclaracion/crearExtra disparan notify; subirComprobantePago pasa uploadedByUserId
  • apps/api/src/services/email/templates/documento-subido.tsnuevo (#9)
  • apps/api/src/services/email/email.service.tssendDocumentoSubido
  • apps/api/src/utils/memberships.tsgetTenantOwnerEmails, getUserEmailById
  • apps/api/src/services/notify-upload.service.tsnuevo (#9 orquestador)
  • apps/api/src/middlewares/feature-gate.middleware.ts — ruteo catálogo despacho/Horux 360 (fix 5.1)
  • packages/shared/src/constants/plans.tshasFeature defensive
  • apps/api/src/controllers/cfdi.controller.ts — nuevo param bucket en drillDown (fix 5.6)
  • apps/api/src/services/sat/sweep-stale-jobs.service.tsnuevo (#3)
  • apps/api/src/jobs/sat-sync.job.ts — cron watchdog cada 2h (#3)
  • apps/api/scripts/sweep-stale-sat-jobs.ts — refactorizado a thin CLI wrapper (#3)
  • apps/api/src/services/contribuyente-facturapi.service.tsuploadCsdContribuyente con validaciones pre-Facturapi (#4)
  • apps/api/src/services/sat/sat-client.service.tsverifySatRequest expone codeRequest (#5)

Frontend

  • apps/web/app/(dashboard)/pendientes/page.tsx — tipo DeclaracionLink + link ↗ (#6)
  • apps/web/app/(dashboard)/calendario/page.tsx — ícono AlertTriangle + leyenda (#7)
  • apps/web/app/(dashboard)/dashboard/page.tsx — drill links usan bucket (fix 5.6)
  • apps/web/app/(dashboard)/impuestos/page.tsx — drill links usan bucket (fix 5.6)
  • apps/web/app/(dashboard)/drill-down/page.tsx — columna "Monto Pago" + sort (fix 5.6)
  • apps/web/app/(dashboard)/documentos/page.tsx — DeclaracionesTab pasa selectedContribuyenteId (fix 5.2)
  • apps/web/lib/api/declaraciones.tslistDeclaraciones con contribuyenteId (fix 5.2)
  • apps/web/lib/hooks/use-declaraciones.ts — hook con contribuyenteId (fix 5.2)

Documentación

  • docs/plans/2026-04-19-pending-features.md — 4 secciones marcadas (#6 #7 #9 #10)
  • docs/plans/2026-04-22-pendientes-y-addons.md — derivados actualizados con #3 #4 #5
  • docs/plans/2026-04-23-features-fixes-y-derivados.mdeste doc

Data directa

  • Zorro + Patito: UPDATE obligacion_periodos para revertir 3 filas completadas por error (DIOT + 2 anuales de ISR) — sus alertas ob-* reabiertas.
  • Declaraciones legacy con contribuyente_id=NULL se dejaron como están (test data, no se eliminaron).

Migrations aplicadas a tenants

  • 030_obligacion_periodos_declaracion_id (Zorro, Patito)
  • 031_declaraciones_contribuyente_id (Zorro, Patito)

Pendientes al cierre

Features pending (de 2026-04-19) — solo queda 1 abierto

  • #2 Convertir /pendientes → "Despacho" con métricas cross-contribuyente.

Derivados abiertos — 10

Prioridad Derivado
Alta Cloudflare Tunnel en prod para MP_NOTIFICATION_URL
Alta Recrear org Facturapi de Carlos (TORC9611214CA)
Media Prueba cross-contribuyente end-to-end
Media Re-sync custom de rangos emitidos de Manuel (depende de capturar codeRequest nuevo)
Baja Recomputar overage al cambiar de plan
Baja FRONTEND_URL dev HTTPS permanente
Baja Typecheck web cleanup (~12 errores)
Baja Declaraciones legacy con contribuyente_id=NULL
Baja Flag por despacho para activar/desactivar emails
Baja Email para declaraciones sin contribuyenteId

Cerrados hoy: #3 watchdog cron, #4 validación CSD, #5 logging SAT, 6 bugfixes.