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:
completarObligacionesPorDeclaracionahora aceptadeclaracionIdy lo persiste en el INSERT/UPDATE delON CONFLICT.createDeclaracionpasadeclaracion.idtras el INSERT.getObligacionesPorPeriodohaceLEFT JOIN declaraciones_provisionalesy devuelve un objetodeclaracion: DeclaracionLink | nullpor cada periodo completado (nuevo tipo exportado).
Frontend (/pendientes/page.tsx):
ObligacionPeriodoextendido condeclaracion: 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:
AlertTriangleicon paraobligacion-atrasada(antes usaba el mismoClockque 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:
removeObligacionhace soft-delete + DELETE alertas + DELETE periodosinactiveFilterengetAlertasManualesPendientesexcluye poractiva=falsecontribuyenteFilterstrict previene leak cross-contribuyentesincronizarDesdeObligacionesContribuyentesolo 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 porkind: '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:crearDeclaracionycrearExtradisparan 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:hasFeaturedefensive 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 adeclaraciones_provisionales.creado_por VARCHAR.creadoPorUserId: string(UUID) — va aobligacion_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:
IMPUESTO_A_OBLIGACION_KEYWORDSahora tieneinclude + exclude:IVA: include=['iva'], exclude=['diot','proveedores de iva','informativa']ISR: include=['isr'], exclude=['retenciones','asimilados a salarios']
- 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
bucketenGET /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+metodoPagoabucket. - 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:
- Parseable + password correcta
!credential.isFiel()— debe ser CSD, no FIELcertificate.rfc() === contribuyente.rfc(uppercase, strict)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 éxito5002 Exhausted— agotadas solicitudes de por vida (mismos parámetros)5003 MaximumLimit— tope máximo de CFDI/Metadata5004 EmptyResult— no hay información en el rango5005 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—completarObligacionesPorDeclaracioncon declaracionId + periodicidad filter + include/exclude keywords;createDeclaracionconcreadoPorUserId;listDeclaracionescon contribuyenteId filter;uploadComprobantePagollama completar obligacionesapps/api/src/services/obligaciones.service.ts—getObligacionesPorPeriodocon LEFT JOIN declaraciones + tipoDeclaracionLinkapps/api/src/controllers/documentos.controller.ts—listarDeclaracioneslee contribuyenteId;crearDeclaracion/crearExtradisparan notify;subirComprobantePagopasa uploadedByUserIdapps/api/src/services/email/templates/documento-subido.ts— nuevo (#9)apps/api/src/services/email/email.service.ts—sendDocumentoSubidoapps/api/src/utils/memberships.ts—getTenantOwnerEmails,getUserEmailByIdapps/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—hasFeaturedefensiveapps/api/src/controllers/cfdi.controller.ts— nuevo parambucketen 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—uploadCsdContribuyentecon validaciones pre-Facturapi (#4)apps/api/src/services/sat/sat-client.service.ts—verifySatRequestexponecodeRequest(#5)
Frontend
apps/web/app/(dashboard)/pendientes/page.tsx— tipoDeclaracionLink+ link ↗ (#6)apps/web/app/(dashboard)/calendario/page.tsx— ícono AlertTriangle + leyenda (#7)apps/web/app/(dashboard)/dashboard/page.tsx— drill links usanbucket(fix 5.6)apps/web/app/(dashboard)/impuestos/page.tsx— drill links usanbucket(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 pasaselectedContribuyenteId(fix 5.2)apps/web/lib/api/declaraciones.ts—listDeclaracionescon 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 #5docs/plans/2026-04-23-features-fixes-y-derivados.md— este doc
Data directa
- Zorro + Patito:
UPDATE obligacion_periodospara revertir 3 filas completadas por error (DIOT + 2 anuales de ISR) — sus alertasob-*reabiertas. - Declaraciones legacy con
contribuyente_id=NULLse 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.