Skip to content

Conversation

@alfredo-rgzm
Copy link

@alfredo-rgzm alfredo-rgzm commented Nov 5, 2025

Corrección Errores CFDI40215 y CFDI40221 - Resumen Técnico

Fecha: 5 de noviembre de 2025
Addon: cdfi_invoice
Archivo modificado: models/account_invoice.py


Problemas Identificados

Error CFDI40215

"El campo Importe correspondiente a Traslado no es igual al redondeo de la suma de los importes de las bases trasladados registrados en los conceptos"

Ejemplo:

Traslado.Importe: 453484.35
Suma de bases redondeadas × tasa: 453484.31
Diferencia: 0.04

Error CFDI40221

"El campo Importe correspondiente a Traslado no es igual al redondeo de la suma de los importes de los impuestos trasladados registrados en los conceptos"

Ejemplo:

TotalImpuestosTrasladados: 19343.93
Suma de Importe en conceptos: 19343.94
Diferencia: 0.01

Causa Raíz

Problema 1: CFDI40215 (Nivel Línea)

El PAC valida que cada concepto (línea de factura) cumpla:

Importe = ROUND(ROUND(Base, 2) × Tasa, 2)

El código original:

  • Base: Valor sin redondear (solo formateado)
  • Importe: Monto calculado por Odoo desde base sin redondear

Consecuencia: Diferencias acumuladas en facturas con múltiples líneas.

Problema 2: CFDI40221 (Resumen vs Líneas)

El PAC valida que el total en resumen sea exactamente la suma de importes en conceptos.

El código original:

  • Calculaba importe a nivel línea: base_línea × tasa
  • Recalculaba en resumen: base_agrupada × tasa
  • Los dos cálculos daban resultados ligeramente diferentes

Consecuencia: Inconsistencia entre totales de conceptos vs resumen (diferencias de 0.01).


Solución Implementada

1. Impuestos Trasladados a Nivel Línea (líneas 387-422)

Antes:

tax_tras.append({
    'Base': self.set_decimals(taxes['base'], no_decimales_prod),
    'Importe': self.set_decimals(taxes['amount'], no_decimales_prod)
})
tras_tot += taxes['amount']

Después:

# Redondear base primero
rounded_base_line = self.roundTraditional(taxes['base'], no_decimales_prod)
# Calcular importe desde base redondeada
calculated_importe_line = self.roundTraditional(rounded_base_line * (tax.amount / 100.0), no_decimales_prod)

tax_tras.append({
    'Base': self.set_decimals(rounded_base_line, no_decimales_prod),
    'Importe': self.set_decimals(calculated_importe_line, no_decimales_prod)
})
tras_tot += calculated_importe_line

2. Impuestos Retenidos a Nivel Línea (líneas 424-442)

Mismo patrón aplicado para retenciones.

3. Totales Resumen (líneas 586-603)

Antes (causaba CFDI40221):

# RECALCULABA desde base agrupada
calculated_importe = self.roundTraditional(rounded_base * (tax.amount / 100.0), no_decimales)
traslados.append({
    'importe': calculated_importe,
    'base': rounded_base
})

Después:

# USA el importe ya calculado a nivel línea (no recalcula)
calculated_importe = self.roundTraditional(line['amount'], no_decimales)

traslados.append({
    'importe': calculated_importe,
    'base': rounded_base
})

# Total es exactamente la suma de importes en traslados
tras_tot = sum([float(t['importe']) for t in traslados if t['importe'] != ''])

Clave: El importe se calcula una sola vez a nivel línea, el resumen lo reutiliza (no lo recalcula).

4. Retenciones Resumen (líneas 609-622)

Cambio clave:

# Antes: recalculaba
calculated_importe_ret = self.roundTraditional(rounded_base_ret * (abs(tax.amount) / 100.0), no_decimales)

# Después: reutiliza
calculated_importe_ret = self.roundTraditional(line['amount'], no_decimales)

5. Impuestos Locales Traslados (líneas 447-452)

Antes:

tax_local_tras_tot += self.roundTraditional(taxes['amount'], 2)
tax_local_tras.append({
    'Importe': self.set_decimals(taxes['amount'], 2)  # Sin redondear
})

Después:

rounded_local_tras = self.roundTraditional(taxes['amount'], 2)
tax_local_tras_tot += rounded_local_tras
tax_local_tras.append({
    'Importe': self.set_decimals(rounded_local_tras, 2)  # Redondeado
})

6. Impuestos Locales Retenciones (líneas 454-459)

Mismo patrón aplicado para retenciones locales.


Cambios por Tipo de Factor

  • Porcentaje (normal): importe = ROUND(ROUND(base, 2) × tasa, 2)
  • Cuota (fijo): importe = ROUND(amount, 2) (monto fijo por unidad)
  • Exento: Sin cambios

Archivos Modificados

models/account_invoice.py

Líneas modificadas:

  • 387-422: Impuestos trasladados nivel línea (conceptos) - Fix CFDI40215
  • 424-442: Retenciones nivel línea (conceptos) - Fix CFDI40215
  • 447-452: Impuestos locales traslados (conceptos) - Fix CFDI40215
  • 454-459: Impuestos locales retenciones (conceptos) - Fix CFDI40215
  • 586-603: Totales traslados resumen - Fix CFDI40221
  • 609-622: Totales retenciones resumen - Fix CFDI40221

Total de líneas cambiadas: ~60 líneas


Compatibilidad

  • ✅ Compatible con CFDI 4.0
  • ✅ Mantiene lógica existente para otros tipos de impuestos
  • ✅ No afecta funcionalidad de impuestos locales
  • ✅ Conserva método roundTraditional() existente
  • ✅ Sin cambios en estructura de datos o base de datos

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants