Reglas de Precisión SAT
El SAT mexicano tiene tolerancia cero a desviaciones en los cálculos fiscales. Cualquier violación —redondeo incorrecto, comparación imprecisa de montos, mezcla de arrays de impuestos— produce diferencias en las declaraciones que obligan a presentar declaraciones complementarias. Este artículo documenta las reglas absolutas que todo código de GM Fiscal debe seguir.
Tabla de reglas absolutas
| Operación | Método correcto | Método prohibido |
|---|---|---|
| Redondear montos | R21MathUtils.RedondearMonto(x) — half-up | math.Round(x) · fmt.Sprintf("%.2f") |
| Comparar montos | R21MathUtils.CompararMontos(a, b) — tolerancia 0.005 | a == b · a - b < 0.01 |
| Comparar UUIDs | strings.EqualFold(uuid1, uuid2) | uuid1 == uuid2 · strings.ToLower manual |
| RFC para SAT/Prodigia | empresa.GetProdigiaRFC() | empresa.RFC |
| RFC para ERP/Importa | empresa.GetServicesRFC() | empresa.RFC |
| RFC para BD | empresa.GetDatabaseRFC() o empresa.RFC | — |
| Parsear XML SAT | RemoveNamespaces(xml) → xml.Unmarshal | xml.Unmarshal sin RemoveNamespaces |
| Impuestos ERP | Array ImpuestosERP separado | Mezclar con ImpuestosProdigia |
| Impuestos SAT | Array ImpuestosProdigia separado | Mezclar con ImpuestosERP |
| CFDIs cancelados en R21 | Excluir siempre | Incluir aunque sea como 0 |
Redondeo half-up SAT
El SAT utiliza el método de redondeo half-up (también llamado redondeo comercial): cuando el dígito a redondear es exactamente 0.5, se redondea hacia arriba. Las funciones nativas de Go usan el método round half to even (también llamado banker’s rounding), que redondea hacia el número par más cercano. Esta diferencia produce montos incorrectos en declaraciones fiscales.
// Correcto — half-up SATmathUtils := reporte.NewR21MathUtils()total := mathUtils.RedondearMonto(1160.005) // → 1160.01 (half-up, sube)total2 := mathUtils.RedondearMonto(1160.015) // → 1160.02 (half-up, sube)
// Incorrecto — math.Round usa round half to eventotal := math.Round(1160.005*100) / 100 // → 1160.00 (round half to even, baja)
// Incorrecto — fmt.Sprintf no garantiza half-uptotal := fmt.Sprintf("%.2f", 1160.005) // comportamiento no definido por SATComparación de montos con tolerancia
Los montos en punto flotante nunca deben compararse con el operador ==. Los errores de precisión de float64 producen comparaciones falsas para montos que matemáticamente son iguales.
// Correcto — tolerancia de 0.005iguales := mathUtils.CompararMontos(1.00, 1.004) // → true (dentro de tolerancia)iguales2 := mathUtils.CompararMontos(1.00, 1.006) // → false (fuera de tolerancia)
// Incorrecto — float64 precision errorsif factura.Total == 1160.00 { } // puede fallar con montos idénticos
// Incorrecto — tolerancia arbitraria no SATif math.Abs(a-b) < 0.01 { } // tolerancia incorrectaInterfaz R21MathUtils
type R21MathUtils interface { RedondearMonto(x float64) float64 // Half-up según normativa SAT. 0.005 → 0.01, 0.015 → 0.02.
CompararMontos(a, b float64) bool // Igualdad con tolerancia 0.005. Para comparar totales e impuestos.
CalcularBaseIVA16(iva float64) float64 // Base imponible para IVA 16%: iva / 0.16
CalcularBaseIVA8(iva float64) float64 // Base imponible para IVA 8% (zona fronteriza): iva / 0.08
AcumularMontos(montos []float64) float64 // Suma de montos con redondeo correcto en cada paso intermedio.}
// Uso:math := reporte.NewR21MathUtils()base := math.CalcularBaseIVA16(160.00) // → 1000.00total := math.RedondearMonto(1160.005) // → 1160.01igual := math.CompararMontos(1.00, 1.004) // → trueParsing XML CFDI
El XML de los CFDIs SAT incluye prefijos de namespace (cfdi:, tfd:, imp:) que interfieren con xml.Unmarshal de Go. El parseo sin limpiar estos prefijos puede completarse sin error pero con campos vacíos, produciendo datos incorrectos de forma silenciosa.
Flujo obligatorio de parsing
// 1. Siempre RemoveNamespaces antes de UnmarshalxmlLimpio := xml_utils.RemoveNamespaces(xmlRaw)
// 2. Unmarshal al struct CFDIvar cfdi dtos.CFDIif err := xml.Unmarshal([]byte(xmlLimpio), &cfdi); err != nil { return fmt.Errorf("parsear CFDI: %w", err)}
// 3. UUID siempre con EqualFoldif strings.EqualFold(cfdi.UUID, uuidBuscado) { ... }
// 4. Montos: parsear como float64 con TrimSpacemonto, err := strconv.ParseFloat(strings.TrimSpace(cfdi.Total), 64)if err != nil { return fmt.Errorf("parsear monto Total: %w", err)}
// 5. Redondear inmediatamente después de parsearmontoRedondeado := mathUtils.RedondearMonto(monto)Estructura del XML CFDI v4.0
<cfdi:Comprobante Version="4.0" TipoDeComprobante="I|E|P|N|T" Total="1160.00" SubTotal="1000.00" NoCertificado="...">
<cfdi:Emisor Rfc="RFC_EMISOR" Nombre="..." RegimenFiscal="601"/> <cfdi:Receptor Rfc="RFC_RECEPTOR" UsoCFDI="G01"/>
<cfdi:Impuestos TotalImpuestosTraslados="160.00"> <cfdi:Traslados> <cfdi:Traslado Impuesto="002" TipoFactor="Tasa" TasaOCuota="0.160000" Importe="160.00"/> </cfdi:Traslados> </cfdi:Impuestos>
<cfdi:Complemento> <tfd:TimbreFiscalDigital UUID="550e8400-e29b-41d4-a716-446655440000"/> </cfdi:Complemento></cfdi:Comprobante>Los campos clave son:
| Campo | Valores posibles | Significado |
|---|---|---|
TipoDeComprobante | I, E, P, N, T | Ingreso, Egreso, Pago, Nómina, Traslado |
Impuesto | 001, 002, 003 | ISR, IVA, IEPS |
TasaOCuota | 0.160000, 0.080000, 0.000000 | 16%, 8%, 0% |
TipoFactor | Tasa, Retencion, Exento | Tipo de aplicación del impuesto |
RFC multi-empresa
La entidad Empresa puede tener hasta tres RFC distintos. El uso del campo incorrecto en consultas externas produce errores de autenticación o resultados vacíos:
// Correctoempresa.GetProdigiaRFC() // para consultas a Prodigia, SAT, Hadesempresa.GetServicesRFC() // para consultas al ERP e Importaempresa.GetDatabaseRFC() // para identificación en BD (= empresa.RFC)
// Incorrecto — puede ser el RFC de prueba en modo TSTempresa.RFCerp.GetFacturas(empresa.RFC) // MALprodigia.GetCFDIs(empresa.RFC) // MALEn el modo TST la empresa tiene RFC="TST123" en la base de datos pero ProdigiaRFC="RFC_REAL_EMPRESA". Las consultas al SAT deben usar el RFC real; de lo contrario devuelven cero facturas.
Anti-patrones prohibidos
El código de GM Fiscal prohíbe explícitamente los siguientes patrones. Su presencia en un pull request es razón suficiente para rechazarlo:
// PROHIBIDO: comparar montos con ==if factura.Total == 1160.00 { }
// PROHIBIDO: redondear con math.Roundtotal := math.Round(monto*100) / 100
// PROHIBIDO: comparar UUIDs con ==if cfdi.UUID == "550e8400..." { }
// PROHIBIDO: usar RFC base para consultas externaserp.GetFacturas(empresa.RFC)
// PROHIBIDO: mezclar arrays de impuestosfc.Impuestos = append(erpImps, prodigiaImps...)
// PROHIBIDO: Unmarshal XML SAT sin limpiar namespacesxml.Unmarshal(xmlRaw, &cfdi)
// PROHIBIDO: incluir CFDIs cancelados en R21if cfdi.EstatusERP == "CANCELADO" { calcularIVA(cfdi) }Invariantes verificables en tests
Las siguientes invariantes se deben verificar en los tests de la capa de dominio. Si alguna falla, indica un error en la lógica de conciliación o procesamiento de impuestos:
// Impuestos nunca mezcladosfor _, fc := range conciliacion.FacturasConciliadas { for _, imp := range fc.ImpuestosERP { assert.Equal(t, "ERP", imp.Origen) } for _, imp := range fc.ImpuestosProdigia { assert.Equal(t, "PRODIGIA", imp.Origen) }}
// Solicitud Conciliada es inmutable_, err := uc.Execute(CambiarEstatusCommand{UUID: sol.UUID, Estatus: "Listo para conciliar"})assert.True(t, shared.IsInvalidTransition(err))
// UUID insensible a mayúsculasuuid := "550E8400-E29B-41D4-A716-446655440000"uuidMinusculas := strings.ToLower(uuid)assert.True(t, strings.EqualFold(uuid, uuidMinusculas))Resumen
- El redondeo half-up es obligatorio para todo monto fiscal.
math.Roundyfmt.Sprintf("%.2f")están prohibidos. - Los montos se comparan con
R21MathUtils.CompararMontoscon tolerancia 0.005, nunca con el operador==. - Los UUIDs SAT se comparan siempre con
strings.EqualFoldpor insensibilidad a mayúsculas/minúsculas. RemoveNamespaces()antes dexml.Unmarshales obligatorio; omitirlo produce parseo silencioso con campos vacíos.- Los arrays
ImpuestosERPeImpuestosProdigianunca se mezclan; la invariante es verificable en tests unitarios. empresa.RFCnunca se usa directamente en consultas externas; siempre se usaGetProdigiaRFC()oGetServicesRFC().