340 lines
14 KiB
Markdown
340 lines
14 KiB
Markdown
# 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.
|