19 KiB
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 conWHERE 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_mensualesinvalidadas con razónI07_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'ystatus 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. UsaresolveContribuyenteContextpara obtenerrfcLengthy 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_mensualescache: 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 deIS_I_PUE_07para 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 portipoRelació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 enbucketCausadoAny/bucketAcreditableAny. Sin filtro tipoRelación (consistente conSUM_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_TRASde 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.
- 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
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
729109fcI PUE: $148K, IVA $20,413 → +$20,413 acreditable (POS, en su mes) - Aplicación
5c874749I PPD/07: $454K, IVA $62,621 → hereda IVA total de las E del mismo mes - NC
7163da3bE PUE/07: $148K, IVA $20,413 → ahora entra al NEG (apunta a I PPD/07) - NC
7aac715bE 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.