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