# 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.ts` — **nuevo**, 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 `periodicidad` ↔ `obligacion.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.sql` — **nuevo** (#6) - `apps/api/src/migrations/tenant/031_declaraciones_contribuyente_id.sql` — **nuevo** (fix 5.2) - `apps/api/src/services/declaraciones.service.ts` — `completarObligacionesPorDeclaracion` con declaracionId + periodicidad filter + include/exclude keywords; `createDeclaracion` con `creadoPorUserId`; `listDeclaraciones` con contribuyenteId filter; `uploadComprobantePago` llama completar obligaciones - `apps/api/src/services/obligaciones.service.ts` — `getObligacionesPorPeriodo` con LEFT JOIN declaraciones + tipo `DeclaracionLink` - `apps/api/src/controllers/documentos.controller.ts` — `listarDeclaraciones` lee contribuyenteId; `crearDeclaracion`/`crearExtra` disparan notify; `subirComprobantePago` pasa uploadedByUserId - `apps/api/src/services/email/templates/documento-subido.ts` — **nuevo** (#9) - `apps/api/src/services/email/email.service.ts` — `sendDocumentoSubido` - `apps/api/src/utils/memberships.ts` — `getTenantOwnerEmails`, `getUserEmailById` - `apps/api/src/services/notify-upload.service.ts` — **nuevo** (#9 orquestador) - `apps/api/src/middlewares/feature-gate.middleware.ts` — ruteo catálogo despacho/Horux 360 (fix 5.1) - `packages/shared/src/constants/plans.ts` — `hasFeature` 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.ts` — **nuevo** (#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.ts` — `uploadCsdContribuyente` con validaciones pre-Facturapi (#4) - `apps/api/src/services/sat/sat-client.service.ts` — `verifySatRequest` 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.ts` — `listDeclaraciones` 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.md` — **este 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.