14 KiB
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—searchConceptosacepta query paramcontribuyenteId, lo sanitiza con regex[^a-f0-9-](convención del repo) y aplicaAND c.contribuyente_id = '...'al join concfdis.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). handleEmisorRegimenChangerecalcula 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. handleConceptoSearchpasaselectedContribuyenteIdal backend.
- Estado
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:
- No replicaba la lógica por grupo de régimen de las cards.
- No filtraba por
metodo_pago(contaba facturas PPD no pagadas para el grupo PF Empresarial). - 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 queryregimenClave.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— pasaregimenSeleccionado.
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}/certificateexitoso → CSD removido.- PUT a
/legalcontax_idrechazado: Facturapi no permite cambiartax_idpost-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á enfacturapi.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
Canceladopero 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
unitKeydefault 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
consumeTimbreahora captura su retorno enconsumedTimbre.- El inner catch (fallo Facturapi) dispara
refundTimbrefire-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— reescritagetIsrMensual(cambio 2)services/constancia.service.ts— fix TS (cambio 3a)services/sat/sat.service.ts— fix TS (cambio 3b)controllers/impuestos.controller.ts— propagarregimenClave(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— pasarregimenSeleccionado(cambio 2)lib/api/facturacion.ts—searchConceptosparam (cambio 1)lib/api/impuestos.ts—getIsrMensualparam (cambio 2)lib/hooks/use-impuestos.ts— propagarregimenClave(cambio 2)
Data / externo
- Patito:
INSERTentimbre_paquetes(cambio 5) - Carlos:
DELETE /certificateen 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)
- Org de Carlos en Facturapi — user la elimina y recrea con CSD correcto;
después actualizar
facturapi_orgs.facturapi_org_iden BD tenant de Patito. - Prevención del bug de org incorrecta — agregar validación en
uploadCsdContribuyenteque verifique que el RFC del certificado coincide con el RFC del contribuyente (pending_step "legal" + cert mismatch → reject). - 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.
- 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 degetIsrMensual. Verificar si requieren regeneración.