Update: nueva version Horux Despachos

This commit is contained in:
consultoria-as
2026-04-27 22:09:36 -06:00
commit 6b36db1403
614 changed files with 125926 additions and 0 deletions

View File

@@ -0,0 +1,339 @@
# 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.