Files
HoruxDespachosNuevo/docs/plans/2026-04-26-i07-ppd-compensacion.md

19 KiB
Raw Blame History

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

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:

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:

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:

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)

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:

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.