Files
HoruxDespachos/docs/plans/2026-04-26-i07-ppd-compensacion.md
2026-04-27 01:11:06 -06:00

492 lines
19 KiB
Markdown
Raw 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-26 — Compensación I/07 PPD + Activos Fijos
Fix focalizado: cuando una I/07 PPD aplica un anticipo y en el **mismo
mes/año** existe una E (cualquier TipoRelacion) que referencia esa
I/07 PPD, la I/07 PPD aporta al bucket = base de la E. Antes el filtro
`metodo_pago = 'PUE'` excluía la I/07 PPD del bucket de facturas pero
la E sí entraba como NC, generando **gasto/ingreso negativo** en el
periodo.
---
## 1. Caso real que motivó el fix
Husberto Ignacio Torres (TOAH680201RA2), agosto-2025, gastos:
| CFDI | Total | IVA | Base | Notas |
|---|---:|---:|---:|---|
| Anticipo `729109FC…` | ? | ? | ? | no en BD del tenant |
| **I/07 PPD `5c874749`** | $454,000 | $62,621 | $391,379 | apunta al anticipo |
| **E/07 PUE `7163da3b`** | $148,000 | $20,414 | $127,586 | apunta a `5c874749` (mismo día 2025-08-08) |
| **E/01 PUE `7aac715b`** | $10,000 | $1,379 | $8,621 | también apunta a `5c874749` (mismo día) |
Patrón observado en BD:
- La I/07 PPD apunta al **anticipo** original.
- La E (07 o 01) apunta a la **I/07 PPD** (no al anticipo).
### Comportamiento previo (Método A puro)
```
I/07 PPD → NO entra al bucket (filtro metodo_pago='PUE')
E/07 PUE → $127,586 (NC normal en Método A)
E/01 PUE → $8,621
Net agosto-2025 = $136,207 ❌ (gasto negativo)
```
El anticipo aportó en su periodo (vía P/PUE original), pero al cancelar
con la E sin que la I/07 PPD haya entrado al universo del bucket, queda
una entrada negativa fantasma.
### Comportamiento nuevo (con compensación)
```
I/07 PPD compensada = +$127,586 + $8,621 = +$136,207
E/07 PUE = $127,586
E/01 PUE = $8,621
Net agosto-2025 = $0 ✓
```
El neto en agosto-2025 vuelve a 0 (el anticipo ya se contó antes y los
pagos P futuros materializarán el resto del servicio cuando lleguen).
---
## 2. Volumen del patrón en BD
Búsqueda con el query nuevo (`scripts/find-i07-ppd-cases.ts` filtro
RFC):
| Contribuyente | I/07 PPD ↔ E referencias directas | Mismo mes/año |
|---|---:|---:|
| Husberto (TOAH680201RA2) | 26 | **23** |
| (resto del tenant) | varios | varios |
23 casos cumplen exactamente la regla "mismo mes/año" en Husberto.
Implementarlo afecta de forma medible el dashboard.
---
## 3. Implementación
### Archivos modificados
`apps/api/src/services/dashboard.service.ts`:
#### `calcularEgresosPorRegimen` — bucket adicional `i07PpdComp`
```sql
SELECT i.regimen_fiscal_receptor AS regimen,
COALESCE(SUM((
SELECT COALESCE(SUM(
COALESCE(e.total_mxn, 0)
- COALESCE(e.iva_traslado_mxn, 0)
- COALESCE(e.ieps_traslado_mxn, 0)
- COALESCE(e.impuestos_locales_trasladado_mxn, 0)
), 0)
FROM cfdis e
WHERE e.tipo_comprobante = 'E'
AND e.status NOT IN ('Cancelado','0')
AND ${esReceptorE} -- alias 'e.'
AND LOWER(i.uuid) = ANY(string_to_array(LOWER(e.cfdis_relacionados), '|'))
AND date_trunc('month', e.fecha_emision) = date_trunc('month', i.fecha_emision)
)), 0) AS monto
FROM cfdis i
WHERE ${esReceptorI} -- alias 'i.'
AND i.tipo_comprobante = 'I' AND i.metodo_pago = 'PPD'
AND i.cfdi_tipo_relacion = '07'
AND i.status NOT IN ('Cancelado', '0')
AND ${FR.replace('fecha_emision', 'i.fecha_emision')}
AND i.regimen_fiscal_receptor = ANY($3)
GROUP BY i.regimen_fiscal_receptor
```
Sumado al bucket de gastos:
```ts
const monto = montoF + montoP + montoI07Comp - montoNC;
```
#### `calcularIngresosPorRegimen` Grupo 1 — bucket simétrico `g1I07PpdComp`
Misma lógica pero del lado **emisor** (`esEmisor` en lugar de
`esReceptor`, `regimen_fiscal_emisor`, filtro a `GRUPO_PF_EMPRESARIAL`).
### Helpers SQL
Para usar `esEmisor`/`esReceptor` con alias en la query, se hace
`replace` inline:
```ts
const esReceptorE = esReceptor.replace(/\brfc_receptor\b/g, 'e.rfc_receptor');
```
`esReceptor` viene de `resolveContribuyenteContext()` como fragmento
`UPPER(rfc_receptor) = 'X_RFC'`. El replace lo prepara para usar con el
alias `e.`.
### Lo que NO se tocó
- **Adquisiciones G01** (`calcularAdquisicionesMercancias`): no se
agregó la compensación todavía. Si surge un caso, replicar el patrón
con `WHERE e.uso_cfdi = 'G01'` adicional.
- **IVA causado/acreditable** (`impuestos.service.ts`): mantiene
compensación NETO_CUSTOM con E/07 (no Método A). La regla de I/07 PPD
↔ E mismo mes podría aplicar también en simetría, pero requiere
análisis por separado y está fuera de este cambio.
---
## 4. Validación
### Typecheck
✅ 0 errores en API.
### Recompute
- 212 filas en `metricas_mensuales` invalidadas con razón
`I07_PPD_COMPENSACION_E_MISMO_MES`.
- 392 filas escritas tras `processAllTenantsInvalidations()`.
- 0 errores.
### Caso de validación
Husberto agosto-2025 gastos: el balance $136,207 generado por las E
sin compensación debe desaparecer y volver a 0 en ese periodo.
---
## 5. Trade-offs y decisiones documentadas
### Solo "mismo mes/año"
La regla del user es "máximo un periodo después". En BD real, ningún
caso de Husberto tiene E "1 mes después" — todos los 23 casos están en
el mismo mes que su I/07 PPD. La regla `date_trunc('month', e.fecha)
= date_trunc('month', i.fecha)` cubre los casos reales.
Si en el futuro aparecen E un mes después con monto significativo, se
puede ampliar a `date_trunc('month', e.fecha) BETWEEN
date_trunc('month', i.fecha) AND date_trunc('month', i.fecha + interval
'1 month')`.
### Cualquier TipoRelacion en la E
La regla original era E/07 (cancelación de anticipo). Pero los casos
reales muestran E/01 también compartiendo `cfdis_relacionados` con la
I/07 PPD (ej. `7aac715b`). Esto es congruente con el bug "TipoRelacion
sospechoso" que ya documentamos: el emisor a veces pone 01 cuando
debería ser 07. La compensación nueva captura ambos correctamente.
### Patrón de referencia: E → I/07 PPD (no E → anticipo)
El patrón observado en BD muestra que las E referencian a la I/07 PPD
directamente. Es el patrón SAT estándar (la E "ajusta" la factura, no
el anticipo). El JOIN se hace por:
```sql
LOWER(i.uuid) = ANY(string_to_array(LOWER(e.cfdis_relacionados), '|'))
```
Si en el futuro aparecieran casos con E → anticipo (otro patrón), se
puede hacer un UNION con el join alternativo.
---
## 6. Pendientes derivados
- **Validar Husberto agosto-2025** post-deploy: ya no debe mostrar
gasto negativo. Si lo hace, revisar si hay otros patrones (E que
referencia el anticipo en lugar de la I/07 PPD).
- **Decidir si aplicar a Adquisiciones G01**.
- **Decidir si aplicar a IVA causado/acreditable** simétricamente.
- **Considerar ampliar tolerancia a 1 mes después** si aparece un caso
real con monto significativo.
---
## 7. Pestaña "Activos Fijos" en /impuestos
Vista informativa nueva para llevar seguimiento de la deducción mensual
proporcional de activos fijos. **No altera dashboard ni ISR** — el SAT
trata estos CFDIs como gasto del periodo, así que el sistema los sigue
contando igual. Esta vista permite al contador planear la deducción
manual en su declaración anual.
### Decisión clave del scope (con el user)
Inicialmente se evaluó excluir activos fijos del bucket de gastos y
del cálculo de ISR. Se descartó porque eso desalineaba el sistema con
el comportamiento del SAT (que sí considera el CFDI como gasto del
periodo) y generaría confusión "el sistema no funciona". Decisión:
sistema se mantiene como está, vista nueva sirve solo para
**seguimiento informativo** del MOI.
### Modelo de cálculo
```
MOI = total_mxn iva_traslado_mxn ieps_traslado_mxn impuestos_locales_trasladado_mxn
porcentajeMensual = porcentajeAnual / 12
mesesTranscurridos = (year(periodo) year(adq)) × 12 + (month(periodo) month(adq)) + 1
acumuladoHastaMes = MIN(MOI, MOI × pctMensual × mesesTranscurridos)
acumuladoHastaMesPrev = MIN(MOI, MOI × pctMensual × (mesesTranscurridos 1))
acreditableEsteMes = acumHasta acumPrev
saldoPendiente = MOI acumHasta
```
Si el activo se da de baja: `mesesAplicables = MIN(mesesTranscurridos,
mesesEntreAdqYBaja)`. A partir del mes posterior a la baja,
`acreditableEsteMes = 0`.
Dividir `% / 12` evita el problema del primer año (mes parcial) y
permite seguimiento natural por periodo.
### Tabla de % LISR Art. 34
| Clave | Concepto | % anual |
|---|---|---:|
| I01 | Construcciones | 5% |
| I02 | Mobiliario y equipo de oficina | 10% |
| I03 | Equipo de transporte | 25% |
| I04 | Equipo de cómputo y accesorios | 30% |
| I05 | Dados, troqueles, moldes, matrices | 35% |
| I06 | Comunicaciones telefónicas | 10% |
| I07 | Comunicaciones satelitales | 8% |
| I08 | Otra maquinaria y equipo | 10% |
### Filtros (qué CFDIs entran a esta vista)
- `tipo_comprobante = 'I'` y `status NOT IN ('Cancelado','0')`
- `uso_cfdi ∈ {I01..I08}`
- Receptor = contribuyente (`esReceptor`)
- `regimen_fiscal_receptor ∈ {601, 606, 611, 612, 625, 626}`
- **Para 626**: solo si `rfcLength === 12` (PM). RESICO PF (RFC 13)
paga tasa plana sin restar deducciones.
### Estados
- `activo`: aún acreditable, no dado de baja, saldo > 0.
- `agotado`: saldo = 0 (MOI ya se dedujo completo según meses
transcurridos).
- `baja_venta` / `baja_desecho` / `baja_otro`: el contador lo dio de
baja con motivo correspondiente.
### Schema (migración 037)
```sql
CREATE TABLE activos_fijos_baja (
id serial PRIMARY KEY,
cfdi_id int NOT NULL REFERENCES cfdis(id) ON DELETE CASCADE,
fecha_baja date NOT NULL,
motivo varchar(20) NOT NULL CHECK (motivo IN ('venta','desecho','otro')),
comentario text,
dado_de_baja_por uuid NOT NULL,
created_at timestamptz DEFAULT now(),
UNIQUE (cfdi_id)
);
```
### Endpoints
```
GET /api/impuestos/activos-fijos?año=YYYY&mes=MM&contribuyenteId=...&estado=...
POST /api/impuestos/activos-fijos/:cfdiId/baja
body: { fechaBaja, motivo: 'venta'|'desecho'|'otro', comentario? }
DELETE /api/impuestos/activos-fijos/:cfdiId/baja
```
### Archivos
- **Migración 037**: `037_activos_fijos_baja.sql`.
- `apps/api/src/services/activos-fijos.service.ts`: cálculo + manejo
de baja. Usa `resolveContribuyenteContext` para obtener `rfcLength`
y filtrar 626 PM.
- `apps/api/src/controllers/activos-fijos.controller.ts`: 3 handlers
con Zod.
- `apps/api/src/routes/impuestos.routes.ts`: 3 rutas montadas en
`/api/impuestos/activos-fijos`.
- `apps/web/components/impuestos/activos-fijos-tab.tsx`: componente
con disclaimer (recordatorio de que es informativa), 4 KPIs (MOI,
acumulado previo, este mes, saldo pendiente), filtro de estado,
tabla con badge + acción de baja/reversa, modal de baja con motivo +
fecha + comentario.
- `apps/web/app/(dashboard)/impuestos/page.tsx`: botón nuevo
"Activos Fijos" en el switch de tabs + render condicional.
### UX claves
- **Disclaimer ámbar** al inicio de la pestaña recordando que el
sistema considera los CFDIs como gasto del periodo (igual que SAT)
y esta vista es solo seguimiento, no afecta cálculos automáticos.
- **Estados visuales** con badge de color (verde/gris/ámbar/rojo).
- **Filtro de estado** (todos/activos/agotados/baja).
- **Acción reversible**: dar de baja siempre se puede revertir
(DELETE en `/baja`) — la fila vuelve a calcular meses normalmente.
### NO se tocó
- `calcularEgresosPorRegimen`, `calcularAdquisicionesMercancias`,
`calcularResumenIsr`, `getIsrMensual`: intactos.
- `metricas_mensuales` cache: no requiere recompute.
- IVA causado/acreditable: sigue incluyendo estos CFDIs como antes.
### Filtro de conceptos por contribuyente (migración 038)
I06 (Comunicaciones telefónicas) y I07 (Comunicaciones satelitales)
suelen usarse para **gastos regulares** (factura de teléfono, internet
satelital) que no son adquisiciones de activos fijos. Para no ensuciar
la vista, el contador puede excluir conceptos por contribuyente.
**Migración 038**:
```sql
ALTER TABLE contribuyentes
ADD COLUMN activos_fijos_usos_excluidos jsonb DEFAULT '[]'::jsonb;
```
**Endpoints**:
```
PUT /api/impuestos/activos-fijos/usos-excluidos
body: { contribuyenteId, usos: ['I06','I07'] }
```
El response del `GET /activos-fijos` incluye `usosExcluidos` (lista
actual) para que el UI muestre badge "N excluidos".
**UI**: botón "Conceptos" en la barra de filtros abre modal con 8
checkboxes (uno por uso I01-I08). Por default todos están marcados
(considerados). Desmarcar = excluir. Persiste en BD.
### Pendientes derivados
- Auto-detectar bajas que vienen de CFDIs tipo egreso emitidos por el
contribuyente que cancelan parcialmente un activo (ej. venta de
equipo). Hoy es manual.
- Vista anual con resumen por concepto y depreciación de cierre.
- Conectar con declaraciones anuales: cuando el contador suba la
declaración anual, mostrar checkbox para "este activo lo apliqué
como deducción este ejercicio" para llevar trazabilidad.
- Considerar nuevos usos CFDI introducidos por SAT en el futuro
(mantener mapa centralizado).
- Permitir excluir CFDIs específicos (no solo conceptos completos)
para casos mixtos (ej. el cliente compra un teléfono celular
ocasional que SÍ es activo, pero la factura mensual del servicio
telefónico también es I06 y NO es activo).
---
## 8. Extensión IVA — compensación I PPD/07 ↔ E (turno 2026-04-26)
### Asimetría que motivó el cambio
El flujo del SAT con anticipo causa el IVA en tres puntos:
- **Anticipo I PUE** — IVA causado/acreditado en su mes (PUE = se causa al emitir).
- **Aplicación I/07** — la factura final que aplica el anticipo. Si es **PUE** aporta su IVA completo; si es **PPD** aporta 0 hasta que llegue el P.
- **E que cancela** — NC formal o cancelación de operación.
En el caso **PUE** (aplicación I PUE/07), la cadena cierra algebraicamente
gracias al filtro `bucketCausadoNeg/Acreditable` que excluye `tipoRelación='07'`
y al SUM_REL_TRAS que compensa la I PUE/07 contra el anticipo. Sin
necesidad de tocar nada.
En el caso **PPD**, la I PPD/07 no aporta nada en su mes (espera al P).
Si en el **mismo mes** existe una E con tipoRelación **≠ 07** que la
referencia, la E entra al `bucketAcreditableNeg` (o `bucketCausadoNeg`)
y resta IVA — pero la I PPD/07 nunca aportó nada que la E pudiera
neutralizar. Resultado: se "pierde" el IVA equivalente a la E.
### Implementación (`apps/api/src/services/impuestos.service.ts`)
Nuevos predicados/helpers:
- `IS_I_PPD_07` — gemelo de `IS_I_PUE_07` para metodo_pago='PPD'.
- `SUM_E_REFERENCING_TRAS(esLadoE)` / `SUM_E_REFERENCING_RET(esLadoE)`
subqueries que suman el IVA de las E's que referencian la I PPD/07
actual, filtrando por **mismo lado** y **mismo mes/año**.
No filtran por `tipoRelación`: en PPD cualquier E que apunte a la
I PPD/07 cuenta (incluyendo las 07, fiscalmente correctas).
- `HAS_E_REFERENCING_MISMO_MES(esLadoE)` — EXISTS para incluir las
I PPD/07 en `bucketCausadoAny`/`bucketAcreditableAny`. Sin filtro
tipoRelación (consistente con `SUM_E_REFERENCING_*`).
- `E_REFERENCIA_I_PPD_07_MISMO_MES(esLadoIAlias)` — EXISTS desde la
fila E que verifica si esta E referencia una I PPD/07 del mismo
lado/mes. Permite distinguir dos clases de E/07:
- E/07 → anticipo I PUE puro (triángulo PUE clásico): EXISTS = false
→ la E/07 queda excluida del NEG (statu quo, la lógica
`SUM_REL_TRAS` de la I PUE/07 ya cierra el ciclo).
- E/07 → I PPD/07 (cancelación de operación PPD): EXISTS = true
→ la E/07 entra al NEG y resta IVA. La I PPD/07 hereda el mismo
IVA via `SUM_E_REFERENCING_*`, neteando dentro del mes.
`bucketCausadoNeg` y `bucketAcreditableNeg` extendidos con el
disyuntivo `OR E_REFERENCIA_I_PPD_07_MISMO_MES(...)` para que las
E/07 que apuntan a I PPD/07 no queden filtradas. Los aliases `e` y
`i` se derivan de `ctx.esEmisor`/`ctx.esReceptor` con el rewrite
`replace(/\brfc_(emisor|receptor)\b/g, 'e.rfc_$1' | 'i.rfc_$1')`.
Rama nueva en los 4 signed exprs (`signedCausadoTras/Ret`,
`signedAcreditableTras/Ret`):
```
WHEN ${esLado} AND ${IS_I_PPD_07} THEN ${SUM_E_REFERENCING_*(esLadoE)}
```
### Por qué la versión inicial filtraba `<> '07'` (descartado)
La primera implementación filtraba `tipoRelación <> '07'` en
`SUM_E_REFERENCING_*` y `HAS_E_REFERENCING_MISMO_MES`, asumiendo que
las E/07 estaban universalmente excluidas del NEG y que heredarlas
sobre-acreditaría. Eso era cierto solo para el triángulo PUE puro,
pero **ignoraba el caso fiscalmente correcto**: una E/07 que cancela
una I PPD/07 sí debe restar IVA, porque la I PPD nunca aportó nada
en su mes.
La corrección es discriminar **a qué apunta la E**, no qué tipoRelación
tiene. Si apunta a una I PPD/07 → afecta IVA simétricamente (E resta
en NEG, I PPD hereda en POS, netean a 0). Si apunta a un anticipo
I PUE puro → queda excluida (statu quo).
### Validación con caso real Husberto 2025-08
Receptor TOAH680201RA2 con 4 CFDIs en `cfdis_relacionados` enredados:
- Anticipo `729109fc` I PUE: $148K, IVA $20,413 → +$20,413 acreditable (POS, en su mes)
- Aplicación `5c874749` I PPD/07: $454K, IVA $62,621 → hereda IVA total de las E del mismo mes
- NC `7163da3b` E PUE/07: $148K, IVA $20,413 → ahora entra al NEG (apunta a I PPD/07)
- NC `7aac715b` E PUE/01: $10K, IVA $1,379 → entra al NEG (tipoRelación ≠ 07)
| Concepto | Aporte agosto 2025 |
|---|---:|
| Anticipo I PUE (POS) | + $20,413.79 |
| I PPD/07 hereda E/07 + E/01 (rama nueva) | + $21,793.10 |
| E/07 (NEG, ahora incluida porque apunta a I PPD/07) | $20,413.79 |
| E/01 (NEG, ya estaba) | $1,379.31 |
| **Total acreditable** | **$20,413.79** |
| Estado | Acreditable agosto 2025 |
|---|---:|
| Antes del cambio | $20,413.79 + 0 + 0 $1,379.31 = **$19,033.69** |
| Después (versión inicial con filtro `<> '07'`) | $20,413.79 + $1,379.31 + 0 $1,379.31 = **$20,413.79** |
| Después (versión refinada sin filtro) | Ver tabla ↑ = **$20,413.79** |
**Delta total vs antes: +$1,379.31 acreditable recuperado.** Las dos
versiones (con/sin filtro) dan el mismo resultado en el caso Husberto
porque la E/01 y la E/07 cubren montos distintos. La versión refinada
es necesaria para casos donde **solo existe la E/07** (lo correcto
fiscalmente): sin la condición nueva en `bucketAcreditableNeg`, la
E/07 quedaría excluida y la I PPD nunca sería incluida en el bucket
Any → la compensación no ocurriría.
### Cache `metricas_mensuales`
`computeMetricaMensual` en `metricas-compute.service.ts` llama a
`getResumenIva` que ya usa los signed exprs nuevos — futuros recomputes
escriben los valores correctos. Periodos cacheados con la lógica vieja
quedan stale hasta invalidarse. Pendiente: barrido de invalidación por
periodo donde existan I PPD/07 + E/(≠07) referenciándolas en mismo mes.
### Por qué no se aplicó al caso PUE
El caso anticipo I PUE + I PUE/07 + E/07 ya cierra con la lógica
existente (compensación SUM_REL_TRAS en I PUE/07, exclusión de E/07
del NEG). Algebraicamente equivalente al flujo "natural" donde la E/07
restaría — la diferencia es que el código actual es **robusto al caso
"no se emite E/07"** (común en aplicaciones íntegras), donde el flujo
natural sobrecausaría. Cambiar PUE rompería ese caso típico para
ganar nada en el atípico.