Skip to content

Impuestos.Traslados: duplicate rate keys silently overwrite each other, losing tax amounts #84

Description

@MarxCha

Summary

When a CFDI declares two <cfdi:Traslado> entries with the same tax, factor type and rate, but with the rate written in different notations (TasaOCuota="0.16" vs "0.160000"), the parser normalizes the rate when building the index key. Both entries collapse into the same key and the second one silently overwrites the first, so the amount of the overwritten entry is lost.

This is not a malformed document: it is valid CFDI 4.0 and the SAT accepts and stamps it. The TotalImpuestosTrasladados attribute of the same node states the amount that should be there.

Reproduction

Minimal synthetic CFDI (all data invented — test RFCs, made-up amounts):

minimo.xml
<?xml version="1.0" encoding="UTF-8"?>
<cfdi:Comprobante xmlns:cfdi="http://www.sat.gob.mx/cfd/4"
  Version="4.0" Fecha="2026-01-01T00:00:00" SubTotal="100.00" Total="116.00"
  Moneda="MXN" TipoDeComprobante="I" Exportacion="01" LugarExpedicion="00000"
  Sello="SELLO_DE_PRUEBA" Certificado="CERT_DE_PRUEBA" NoCertificado="00000000000000000000"
  Folio="1" Serie="TEST" FormaPago="01" MetodoPago="PUE">
  <cfdi:Emisor Rfc="AAA010101AAA" Nombre="EMISOR DE PRUEBA" RegimenFiscal="601"/>
  <cfdi:Receptor Rfc="BBB010101BBB" Nombre="RECEPTOR DE PRUEBA"
    DomicilioFiscalReceptor="00000" RegimenFiscalReceptor="601" UsoCFDI="G03"/>
  <cfdi:Conceptos>
    <cfdi:Concepto ClaveProdServ="01010101" Cantidad="1" ClaveUnidad="H87"
      Descripcion="A" ValorUnitario="40.00" Importe="40.00" ObjetoImp="02">
      <cfdi:Impuestos><cfdi:Traslados>
        <cfdi:Traslado Base="40.00" Impuesto="002" TipoFactor="Tasa" TasaOCuota="0.160000" Importe="6.40"/>
      </cfdi:Traslados></cfdi:Impuestos>
    </cfdi:Concepto>
    <cfdi:Concepto ClaveProdServ="01010101" Cantidad="1" ClaveUnidad="H87"
      Descripcion="B" ValorUnitario="60.00" Importe="60.00" ObjetoImp="02">
      <cfdi:Impuestos><cfdi:Traslados>
        <cfdi:Traslado Base="60.00" Impuesto="002" TipoFactor="Tasa" TasaOCuota="0.16" Importe="9.60"/>
      </cfdi:Traslados></cfdi:Impuestos>
    </cfdi:Concepto>
  </cfdi:Conceptos>
  <cfdi:Impuestos TotalImpuestosTrasladados="16.00">
    <cfdi:Traslados>
      <cfdi:Traslado Base="40.00" Impuesto="002" TipoFactor="Tasa" TasaOCuota="0.160000" Importe="6.40"/>
      <cfdi:Traslado Base="60.00" Impuesto="002" TipoFactor="Tasa" TasaOCuota="0.16" Importe="9.60"/>
    </cfdi:Traslados>
  </cfdi:Impuestos>
</cfdi:Comprobante>
from satcfdi.cfdi import CFDI
c = CFDI.from_file("minimo.xml")
tr = c["Impuestos"]["Traslados"]

len(tr)                                      # -> 1     (expected 2)
list(tr)                                     # -> ['002|Tasa|0.160000']
tr['002|Tasa|0.160000']['Importe']           # -> 9.60  (the first Traslado is gone)
sum(v['Importe'] for v in tr.values())       # -> 9.60
c["Impuestos"]["TotalImpuestosTrasladados"]  # -> 16.00

Expected: the sum of the parsed traslados equals TotalImpuestosTrasladados (16.00).
Actual: 9.60. The Base="40.00" / Importe="6.40" entry is dropped with no error and no warning.

Why it matters

The failure is silent: no exception, no warning, and the resulting object looks perfectly well-formed. A consumer summing Traslados gets a smaller tax figure and has no way of noticing unless it independently compares the sum against TotalImpuestosTrasladados.

We hit this in production on real stamped invoices while investigating what looked like a double-counting bug in our own parser. It was not ours — the amounts were being lost upstream, not duplicated. The XML above is a synthetic reduction of that case.

Suggested fix

When the normalized key already exists, accumulate Base and Importe instead of replacing the entry (or keep the traslados as a list and let consumers group them).

⚠️ One trap worth flagging, because it is the first fix that comes to mind and it makes things worse: do not deduplicate by (Base, Importe). In two of the real documents we measured, the two traslados carried identical Base and identical Importe and differed only in the rate notation — they are legitimately two separate entries. Deduplicating them would halve the tax instead of fixing it.

Comparing rates as Decimal (Decimal("0.16") == Decimal("0.160000")) is the right way to decide that two entries belong together; the values still have to be summed, never discarded.

Possibly related

#29 (Tasa 0.16 genera excepción "Unkown Impuesto" en REP) is a different failure, but it comes from the same place: the notation used for TasaOCuota in an otherwise valid document. Issuers do write both 0.16 and 0.160000, sometimes within the same comprobante.

Environment

  • satcfdi 4.9.8
  • Python 3.12
  • CFDI 4.0

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions