# Sesión 2026-04-21 — Facturación, ISR, aislamiento entre contribuyentes ## Resumen ejecutivo Sesión enfocada en el pipeline de facturación del fork Horux Despacho: bugs de routing por contribuyente, cómputo ISR mensual, manejo de timbres ante fallos, y dos errores TS pre-existentes del fork. 10 cambios de código + 1 script de backfill + 1 data fix manual (timbres al despacho Patito). --- ## 1. Selector de régimen emisor + filtro por contribuyente en "Conceptos previos" (#factura-regimen) **Problema:** Contribuyentes con múltiples regímenes (p.ej. Carlos: 606,612,614) no tenían forma de elegir cuál régimen usar al facturar. Además, la búsqueda de "conceptos previos" traía conceptos de TODOS los contribuyentes del tenant, no solo del seleccionado. **Fix:** - `apps/api/src/controllers/facturacion.controller.ts` — `searchConceptos` acepta query param `contribuyenteId`, lo sanitiza con regex `[^a-f0-9-]` (convención del repo) y aplica `AND c.contribuyente_id = '...'` al join con `cfdis`. - `apps/web/lib/api/facturacion.ts` — `searchConceptos(q, tipo?, contribuyenteId?)`. - `apps/web/app/(dashboard)/facturacion/page.tsx`: - Estado `emisorRegimenes` (lista completa, antes solo guardaba el primero). - `handleEmisorRegimenChange` recalcula recomendaciones de retenciones para todos los conceptos en pantalla al cambiar régimen. - Selector UI en la Card "Datos del Comprobante" junto a "Tipo de Comprobante" (posición final tras mover desde "Conceptos"). Visible solo si `tipoComprobante === 'I'` **y** hay ≥2 regímenes detectados. - `handleConceptoSearch` pasa `selectedContribuyenteId` al backend. --- ## 2. Bug ISR — tabla "Histórico ISR" no coincidía con cards **Problema:** Card mostraba $941,359 para Husberto régimen 612 Octubre 2025; tabla mostraba $1,745,862.95. Diferencia ~$800K. **Causa raíz:** `getIsrMensual` usaba un SQL inline simple (`tipo_comprobante IN ('I','P')`) que: 1. No replicaba la lógica por grupo de régimen de las cards. 2. No filtraba por `metodo_pago` (contaba facturas PPD no pagadas para el grupo PF Empresarial). 3. No restaba notas de crédito (tipo E). Las cards usan `calcularIngresosPorRegimen` en `dashboard.service.ts` que separa: - **Grupo PF Empresarial** (606, 612, 621, 625, 626): `I PUE + P − E PUE` - **Grupo PM y otros**: `I PUE+PPD − E PUE` **Fix:** Reescrito `getIsrMensual` en `impuestos.service.ts` para invocar `calcularIngresosPorRegimen`/`calcularEgresosPorRegimen` **mes a mes** (12 iteraciones). Garantiza que la tabla cuadre célula a célula con las cards. Costo: ~72 queries por carga — aceptable en dev y sub-segundo en prod. Propagación de `regimenClave` en toda la stack: - `impuestos.service.ts:getIsrMensual` — nuevo param. - `impuestos.controller.ts` — lee query `regimenClave`. - `apps/web/lib/api/impuestos.ts` — propaga. - `apps/web/lib/hooks/use-impuestos.ts` — incluye en queryKey. - `apps/web/app/(dashboard)/impuestos/page.tsx:43` — pasa `regimenSeleccionado`. Cuando `regimenClave` está presente, usa fórmula por régimen (`REGIMENES_RESTA_DEDUCCIONES.includes(clave)` determina si resta deducciones). --- ## 3. Dos TS errors pre-existentes — `pnpm typecheck` vuelve a cero **Problema:** `pnpm typecheck` en `@horux/api` tenía 2 errores heredados del pivot. ### 3a. `constancia.service.ts:331` — código muerto ```ts calle: cleanDomField(rawDom.nombreVialidad) || cleanDomField(rawDom.calle) || '' // ^^^^^^^^^^^^^^ // Domicilio no tiene campo 'calle' — solo nombreVialidad y yCalle ``` **Fix:** Eliminado el fallback muerto. **Impacto runtime:** Cero — era código inalcanzable. ### 3b. `sat/sat.service.ts:1073` — retry path con CFDIs huérfanos ```ts // El retry handler construía SyncContext SIN contribuyenteId, // aunque la interfaz lo requería como 'string | null'. const ctx: SyncContext = { fielData, service, rfc, tenantId, databaseName, getPool }; // ^ faltaba contribuyenteId ``` **Fix:** Agregado `contribuyenteId: job.contribuyenteId ?? null` (el modelo `SatSyncJob` ya tiene esa columna como `String?`). **Impacto runtime (grave en prod despacho):** antes del fix, CFDIs descargados por retry del cron SAT se insertaban con `contribuyente_id = NULL` en vez del UUID correcto → quedaban huérfanos y no aparecían en métricas per-contribuyente. Solo afectaba despachos (Horux360 clásico no tiene contribuyentes). --- ## 4. Script de backfill de `cfdis.contribuyente_id` **Archivo nuevo:** `apps/api/scripts/backfill-cfdi-contribuyente.ts` Asocia CFDIs huérfanos (contribuyente_id NULL) con el contribuyente cuyo RFC coincide con `rfc_emisor` (EMITIDO) o `rfc_receptor` (RECIBIDO). Idempotente, transaccional por tenant, soporta `--dry` flag. Uso: ```bash pnpm --filter @horux/api exec tsx scripts/backfill-cfdi-contribuyente.ts --dry pnpm --filter @horux/api exec tsx scripts/backfill-cfdi-contribuyente.ts ``` **Resultado en la instancia dev:** 0 CFDIs huérfanos (no hubo sync por retry previo al fix 3b). Script queda listo para cuando aparezcan huérfanos en prod. --- ## 5. Data fix — 20 timbres al despacho Patito `INSERT INTO timbre_paquetes` con: - `tenant_id = 31400c73-6dec-4c29-86cd-1184d86c58b7` (Patito) - `payment_id = NULL` (admin grant manual, soportado por el schema) - `cantidad = 20`, `precio = 0`, `expira_en = 2027-04-21` --- ## 6. Bug crítico — `contribuyenteId` no se enviaba al emitir **Problema:** POST `/facturacion/emitir` devolvía 500 "Organización Facturapi sin API key" al intentar facturar con Horux 360 como contribuyente. **Causa raíz:** El frontend no incluía `contribuyenteId` en el payload. El controller cae al fallback `facturapiService.createInvoice(tenantId, ...)` que intentaba usar la org del **despacho** (Patito, sin API key) en vez de la org del **contribuyente** (Horux 360, configurada). **Fix:** `handleSubmit` en `facturacion/page.tsx` agrega: ```ts ...(selectedContribuyenteId ? { contribuyenteId: selectedContribuyenteId } : {}) ``` --- ## 7. Error middleware propaga mensaje real de Facturapi **Problema:** Cualquier error Facturapi se convertía en 500 "Internal server error" genérico (errorMiddleware solo propaga mensajes de `AppError`). El user solo veía "500" en el navegador; el mensaje real quedaba enterrado en logs. **Fix en `facturacion.controller.ts:emitir`:** ```ts try { invoice = contribuyenteId ? await createInvoiceContribuyente(...) : await facturapiService.createInvoice(...); } catch (err: any) { console.error('[facturacion.emitir] Rechazo al crear factura:', { tenantId, contribuyenteId, type, items, error: err.message, }); throw new AppError(400, err?.message || 'Error al emitir factura'); } ``` Ahora el frontend (que ya tiene `alert(err.response?.data?.message || ...)`) muestra mensajes descriptivos como `"items[0].product.taxes[0].rate" must be one of [...]`. --- ## 8. Org Facturapi de Carlos — CSD eliminado vía API **Diagnóstico:** La org Facturapi de Carlos (`69e6eeb14c9600bdf19c8b29`) tenía datos inconsistentes — `legal.name = "CARLOS HUSBERTO TORRES ROMERO"` pero `legal.tax_id = "HTS240708LJA"` (RFC de Horux 360, no de Carlos). Probablemente se subió el CSD incorrecto al crear la org. Facturapi rechaza porque `nombre ≠ RFC` en su padrón. **Acciones:** - `DELETE /v2/organizations/{id}/certificate` exitoso → CSD removido. - PUT a `/legal` con `tax_id` rechazado: Facturapi no permite cambiar `tax_id` post-creación (protección por diseño). - **Conclusión:** El user elimina la org manualmente y la recrea con el CSD correcto de Carlos (o uno de prueba: Facturapi publica CSDs de prueba en https://docs.facturapi.io/guides/quick-start con RFC `EKU9003173C9`). **Paso pendiente:** al recrear, actualizar referencia en BD: ```sql UPDATE facturapi_orgs SET facturapi_org_id = '', csd_uploaded = true WHERE contribuyente_id = '414b22a8-c6e2-4f39-be0f-7537a848107e'; ``` --- ## 9. Bugs graves de aislamiento entre contribuyentes **Problema encontrado en auditoría:** El fork añadió `createInvoiceContribuyente` para emitir con la org del contribuyente, pero **cancelar, descargar PDF/XML y enviar email siguen usando la org del despacho**. Como el `facturapi_id` de la factura solo existe en la org del contribuyente, todas esas operaciones fallan con 404 silenciosamente o generan inconsistencia BD vs SAT. ### Funciones nuevas en `contribuyente-facturapi.service.ts` - `cancelInvoiceContribuyente(pool, contribuyenteId, facturapiId, motive, substitution?)` - `downloadPdfContribuyente(pool, contribuyenteId, facturapiId)` - `downloadXmlContribuyente(pool, contribuyenteId, facturapiId)` - `sendInvoiceByEmailContribuyente(pool, contribuyenteId, facturapiId, email)` - Helper `streamToBuffer` (copia del que está en `facturapi.service.ts`) ### Routing en `facturacion.controller.ts` | Endpoint | Cómo decide qué org usar | |---|---| | `emitir` → `sendInvoiceByEmail` post-emisión | Usa `contribuyenteId` del request body | | `cancelar` | `SELECT facturapi_id, contribuyente_id FROM cfdis WHERE uuid = $1` y rutea | | `downloadPdf` / `downloadXml` | Helper `resolveCfdiContribuyenteId(pool, facturapiId)` lee la fila | ### Efectos que esto previene - Emails de facturas de contribuyentes al receptor **ahora sí se envían** (antes caían en `.catch(log)` silencioso). - Cancelación de CFDIs de contribuyentes actualiza Facturapi/SAT, no solo BD local → evita inconsistencia donde BD dice `Cancelado` pero SAT sigue vigente. - PDF/XML descargables desde la lista de CFDIs para facturas de contribuyentes. --- ## 10. Reset de formulario al cambiar contribuyente (aislamiento UI) **Problema:** Al cambiar contribuyente en el selector, los campos del formulario (receptor, conceptos, retenciones calculadas, uso CFDI, etc.) quedaban pegados. Escenario concreto: emites con Carlos (PF → retenciones auto) y cambias a Horux 360 (PM → sin retenciones), pero los conceptos mantienen las retenciones que ya no aplican. **Fix:** `useEffect` en `facturacion/page.tsx` con `firstRenderRef` guard que, al cambiar `selectedContribuyenteId` (después del primer render), resetea: - receptor (taxId, legalName, taxSystem, email, zip) - isGlobal flag, extranjeroTaxId, extranjeroCountry - usoCfdi, formaPago, metodoPago, moneda, exportacion (defaults según tipoComprobante) - serie, folio, condiciones - conceptos → uno vacío con `unitKey` default según tipo - relatedUuid, relatedRelationship - pagoUuid, pagoMonto, pagoParcialidad, pagoSaldoAnterior, pagoFormaPago, pagoIvaBase, pagoIvaTasa Conservados intencionalmente: `tipoComprobante` (decisión activa del usuario), `emisorRegimen`/`emisorRegimenes` (el otro useEffect los recarga al cambiar RFC). --- ## 11. Timbres ya no se gastan en emisiones fallidas **Problema:** `consumeTimbre(tenantId)` se invocaba **antes** de llamar a Facturapi; si Facturapi rechazaba, el timbre ya estaba descontado. El outer catch tenía un comentario engañoso que explícitamente decía "No revertir". **Fix:** ### `apps/api/src/services/facturapi.service.ts` Nueva función: ```ts export async function refundTimbre( tenantId: string, consumed: { source: 'mensual' | 'paquete'; paqueteId?: number }, ): Promise ``` Usa `prisma.$transaction` (igual que `consumeTimbre` → atómico). Tiene guards para no bajar de 0. Decrementa por fuente exacta (mensual vs paquete específico via `paqueteId`). ### `apps/api/src/controllers/facturacion.controller.ts` - `consumeTimbre` ahora captura su retorno en `consumedTimbre`. - El inner catch (fallo Facturapi) dispara `refundTimbre` fire-and-forget con log de inconsistencia si el refund mismo falla. - Removido el comentario engañoso del outer catch + añadida explicación de la semántica nueva: refund solo aplica a fallo de emisión; errores post-timbrado (INSERT en `cfdis`) NO hacen refund porque el CFDI ya está sellado en el SAT. ### Tabla de comportamiento nuevo | Escenario | ¿Gasta timbre? | |---|---| | Emisión exitosa | ✅ | | Facturapi rechaza (validación, CSD, rate inválido, etc.) | ❌ se revierte | | Error antes del consume (no hay timbres) | ❌ nunca se intentó | | Error post-timbrado (INSERT falla con CFDI ya sellado) | ⚠️ sí — inconsistencia logeada | --- ## Archivos tocados ### Backend (`apps/api/src/`) - `controllers/facturacion.controller.ts` — múltiples edits (cambios 1, 6, 7, 9, 11) - `services/facturapi.service.ts` — +`refundTimbre` (cambio 11) - `services/contribuyente-facturapi.service.ts` — +4 funciones (cambio 9) - `services/impuestos.service.ts` — reescrita `getIsrMensual` (cambio 2) - `services/constancia.service.ts` — fix TS (cambio 3a) - `services/sat/sat.service.ts` — fix TS (cambio 3b) - `controllers/impuestos.controller.ts` — propagar `regimenClave` (cambio 2) - `scripts/backfill-cfdi-contribuyente.ts` — **nuevo** (cambio 4) ### Frontend (`apps/web/`) - `app/(dashboard)/facturacion/page.tsx` — múltiples edits (cambios 1, 6, 10) - `app/(dashboard)/impuestos/page.tsx` — pasar `regimenSeleccionado` (cambio 2) - `lib/api/facturacion.ts` — `searchConceptos` param (cambio 1) - `lib/api/impuestos.ts` — `getIsrMensual` param (cambio 2) - `lib/hooks/use-impuestos.ts` — propagar `regimenClave` (cambio 2) ### Data / externo - Patito: `INSERT` en `timbre_paquetes` (cambio 5) - Carlos: `DELETE /certificate` en Facturapi (cambio 8, user recrea manualmente) --- ## Estado TS al cierre `pnpm typecheck` en `@horux/api`: **0 errores** (primera vez limpio desde antes de los fixes). Errores pendientes en `@horux/web` son todos pre-existentes y no relacionados con esta sesión (cfdi, admin/usuarios, sidebar-compact, etc.). --- ## Pendientes relacionados (para seguimiento) 1. **Org de Carlos en Facturapi** — user la elimina y recrea con CSD correcto; después actualizar `facturapi_orgs.facturapi_org_id` en BD tenant de Patito. 2. **Prevención del bug de org incorrecta** — agregar validación en `uploadCsdContribuyente` que verifique que el RFC del certificado coincide con el RFC del contribuyente (pending_step "legal" + cert mismatch → reject). 3. **Emitir pruebas cross-contribuyente** una vez Carlos esté recreado: Horux 360 → Carlos → (contribuyente sin org) → validar que timbres pool sea compartido, formularios se resetean, y mensajes de error son claros. 4. **Backfill ISR hot/cold** — las métricas pre-calculadas de años pasados (tablas `metricas_mensuales`, `acumuladas_anuales`) pueden estar basadas en el SQL antiguo de `getIsrMensual`. Verificar si requieren regeneración.