# 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.
