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

340 lines
14 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 = '<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 |
|---|---|
| `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<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.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.