Files
HoruxDespachosNuevo/docs/plans/2026-04-21-facturacion-fixes-session.md

14 KiB
Raw Permalink Blame History

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.tssearchConceptos 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.tssearchConceptos(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

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

// 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:

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:

...(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:

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:

UPDATE facturapi_orgs
SET facturapi_org_id = '<nuevo-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
emitirsendInvoiceByEmail 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:

export async function refundTimbre(
  tenantId: string,
  consumed: { source: 'mensual' | 'paquete'; paqueteId?: number },
): Promise<void>

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.tsnuevo (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.tssearchConceptos param (cambio 1)
  • lib/api/impuestos.tsgetIsrMensual 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.