# 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.
