DEV Community

Cover image for Por que a SEFAZ rejeita sua NF-e (e a culpa é do IEEE 754)
Vilson Neto
Vilson Neto

Posted on

Por que a SEFAZ rejeita sua NF-e (e a culpa é do IEEE 754)

Abra o console do navegador e digite:

1.064 * 39680
// 42219.520000000004
Enter fullscreen mode Exit fullscreen mode

O resultado correto é 42219.52. O seu computador diz 42219.520000000004.

Não é bug do JavaScript. É IEEE 754, o padrão de ponto flutuante que Python, C, Perl e JavaScript usam para armazenar decimais. É o mesmo motivo pelo qual 0.1 + 0.2 === 0.30000000000000004. Para a maioria das aplicações, a diferença não importa. Para emissão de NF-e no Brasil, importa. A SEFAZ compara o seu cálculo com o dela e rejeita a nota se divergir.

Vale dizer: IEEE 754 não é um padrão ruim. Foi desenhado para computação científica, onde precisão relativa importa mais que representação decimal exata. Funciona bem para física, gráficos, machine learning. O problema é que sistemas fiscais precisam de aritmética decimal exata, e isso é fundamentalmente diferente do que IEEE 754 oferece.

Neste artigo: onde o erro nasce, por que as soluções óbvias não funcionam e como eliminar o problema na raiz.

Índice


Rejeição 629: vProd ≠ vUnCom × qCom

A regra 629 verifica se o valor total do produto (vProd) é igual ao preço unitário (vUnCom) multiplicado pela quantidade (qCom).

O erro não está no cálculo. Está na atribuição. Quando você escreve const preco = 1.064, o valor que entra na memória já não é 1.064.

IEEE 754 usa 64 bits para representar um número decimal: 1 de sinal, 11 de expoente e 52 de mantissa. Muitos decimais não têm representação exata em base 2. O que acontece com 1.064:

Representação do número 1.064 em ponto flutuante IEEE 754 mostrando sinal, expoente e mantissa, evidenciando a aproximação binária do valor decimal.

const preco = 1.064     // armazenado como ~1.0640000000000001
const qtd = 39680        // inteiro, representação exata
const total = preco * qtd // 42219.520000000004
Enter fullscreen mode Exit fullscreen mode

Nesse caso específico, toFixed(2) ainda produz '42219.52' porque o drift é pequeno demais pra afetar o arredondamento. Mas nem sempre. Dois cenários reais onde o drift causa rejeição:

Exemplos práticos de erro de arredondamento em JavaScript com cálculos de preço por litro e divisão de embalagem, mostrando divergência de R$ 0,01 após uso de toFixed(2).

Combustível: posto vende gasolina a R$ 5,799/L. Cliente abastece 15 litros.

5.799 * 15
// 86.985 ← aparenta estar certo
(5.799 * 15).toFixed(2)  // '86.98' ← errado, HALF_UP daria '86.99'
Enter fullscreen mode Exit fullscreen mode

O valor armazenado é 86.984999999999999. O 5 da terceira casa virou 4. R$ 0,01 de diferença. Rejeição 629.

Distribuidora: compra pack de 6 refrigerantes a R$ 6,99. Vende 9 unidades avulsas.

const vUnCom = 6.99 / 6   // 1.165
(vUnCom * 9).toFixed(2)    // '10.48' ← errado, HALF_UP daria '10.49'
Enter fullscreen mode Exit fullscreen mode

O vUnCom é calculado por divisão, como todo ERP faz. O drift acumula na multiplicação: 10.484999999999999. Mais R$ 0,01. Mais uma rejeição.

O mesmo padrão se repete em grãos, farmacêutico, autopeças, material de construção, químico, têxtil. Qualquer mercado onde o preço unitário é calculado por divisão de embalagem e o resultado cai na fronteira de arredondamento.

A SEFAZ não compara só o total da nota. Ela recalcula vUnCom × qCom para cada item individualmente. Se um item diverge, mesmo que a soma total bata, a nota é rejeitada.


Rejeição 630: a regra que os ERPs esquecem

A maioria dos ERPs testa a 629 e ignora a 630. A 630 valida os campos de tributação, que frequentemente têm precisão diferente dos campos comerciais.

A NT 2023.004 define a precisão de cada campo:

Campo Descrição Casas decimais
vUnCom Valor unitário de comercialização até 10
qCom Quantidade comercial até 4
vUnTrib Valor unitário de tributação até 10
qTrib Quantidade tributária até 4
vProd Valor total do produto 2

Quando você multiplica um número com 10 casas por um com 4, o resultado intermediário tem até 14 casas decimais antes do arredondamento para 2. É nesse intervalo que IEEE 754 falha:

// vUnTrib com 10 casas, qTrib com 4 casas
0.0000000012 * 1000.1234
// 0.0000012001480799999999
// resultado exato: 0.00000120014808
Enter fullscreen mode Exit fullscreen mode

A divergência aparece quando a NF-e usa unidades de tributação diferentes da comercialização: litros vs caixas, kg vs unidades. Os campos mudam, a precisão muda, e o drift que passava despercebido na 629 estoura na 630.

Muitos ERPs nem testam a 630 em homologação porque usam a mesma unidade para comercialização e tributação nos testes. Quando o cliente real manda uma nota com unidades diferentes, a rejeição aparece em produção.


O que não funciona

Antes de chegar na solução, vale entender por que as abordagens óbvias falham.

toFixed

O primeiro instinto de todo dev:

(1.064 * 39680).toFixed(2)
// '42219.52' ← funciona neste caso
Enter fullscreen mode Exit fullscreen mode

Funciona por sorte. toFixed arredonda com base no valor em memória, não no valor que você digitou. O número 1.255 é armazenado como 1.2549999... Quando toFixed vê um valor abaixo de .5, arredonda para baixo:

(1.255).toFixed(2)  // '1.25' ← deveria ser '1.26' (HALF_UP)
(2.675).toFixed(2)  // '2.67' ← deveria ser '2.68'
(1.005).toFixed(2)  // '1.00' ← deveria ser '1.01'
Enter fullscreen mode Exit fullscreen mode

O arredondamento padrão da SEFAZ é HALF_UP: dígito >= 5, arredonda para cima. Não é banker's rounding (HALF_EVEN), que distribui arredondamentos estatisticamente. É o arredondamento que você aprendeu na escola. A maioria dos sistemas fiscais do mundo usa HALF_UP.

toFixed não garante HALF_UP porque opera sobre a representação IEEE 754 que já chegou corrompida. O problema não é o algoritmo de arredondamento, é o input que alimenta esse algoritmo.

Math.round

Dois problemas. Primeiro, Math.round arredonda "toward +Infinity", não HALF_UP. Para positivos funciona igual, mas para negativos diverge:

Math.round(-0.5)  // -0  (toward +Infinity)
// HALF_UP seria  // -1  (away from zero)
Enter fullscreen mode Exit fullscreen mode

Segundo, e mais importante: Math.round recebe o mesmo float corrompido. O drift já aconteceu antes da chamada. Nenhuma função de arredondamento resolve se o valor que ela recebe já não é o que você digitou.

Multiplicar por 100 (centavos)

A abordagem clássica: trabalhar em centavos para evitar decimais. Com um preço de 2 casas como R$ 10.64:

const precoEmCentavos = 1064  // R$ 10.64
const qtd = 39680
const total = precoEmCentavos * qtd // 42219520
// Dividir por 100 → R$ 422195.20 ✓
Enter fullscreen mode Exit fullscreen mode

Funciona para valores com 2 casas decimais. O problema é que a NF-e não trabalha com 2 casas. O campo vUnCom aceita até 10 casas decimais, e o qCom até 4. Para representar tudo como inteiro, você precisaria multiplicar por 10^10 e 10^4 respectivamente:

// vUnCom: 1.0640000000 × 10^10 → 10640000000
// qCom:   39680.0000   × 10^4  → 396800000
// Produto: 10640000000 * 396800000 = 4.22 × 10^18
// Number.MAX_SAFE_INTEGER        = 9.007 × 10^15
// Excedeu. Drift de volta.
Enter fullscreen mode Exit fullscreen mode

BigInt

Resolve o overflow, mas não tem divisão decimal nativa:

BigInt(10640000000) * BigInt(396800000)
// 4221952000000000000n ← correto

// Mas: quanto é isso em reais?
// Precisa dividir por 10^14 e arredondar com HALF_UP
// BigInt não tem .round() nem divisão com resto decimal
Enter fullscreen mode Exit fullscreen mode

Você acabaria reimplementando aritmética decimal por cima do BigInt. Nesse ponto, é mais simples operar diretamente sobre strings.

Comparativo

Abordagem Resolve o drift? Garante HALF_UP? Funciona com 10+ casas?
toFixed Não Não Não
Math.round Não Não Não
Centavos (×100) Sim, até 2 casas Manual Não (overflow)
BigInt Sim Manual Sim, mas sem divisão decimal
Strings Sim Sim Sim

Por que strings resolvem

Ilustração da representação de números decimais como strings, destacando estrutura com sinal, dígitos e expoente, e como isso evita erros de precisão do IEEE 754.

Se o número já entra corrompido como float, a solução é não deixar entrar como float.

Quando você passa '1.064' como string, cada dígito é preservado. Não existe conversão para binário. Não existe truncamento de mantissa. Internamente, a representação é:

// { sign: 1 | -1, digits: string, exponent: number }
// '1.064' → { sign: 1, digits: "1064", exponent: -3 }
Enter fullscreen mode Exit fullscreen mode

A multiplicação opera sobre esses dígitos, como você faria no papel:

  1. Multiplica os dígitos inteiros: 1064 × 39680 = 42219520
  2. Soma os expoentes: -3 + 0 = -3
  3. Aplica o expoente: 42219520 × 10^-3 = 42219.520

Resultado exato. Nenhum bit é perdido porque nenhum bit foi usado para representar o número. É texto puro do começo ao fim.

A mesma lógica funciona para qualquer precisão. Dez casas decimais? Sem problema, são só dígitos numa string. Não existe MAX_SAFE_INTEGER para strings. Não existe truncamento de mantissa.

Essa não é uma ideia nova. Bancos de dados fazem isso há décadas. O tipo NUMERIC/DECIMAL do PostgreSQL armazena dígitos, não floats. O Java tem BigDecimal. Python tem o módulo decimal. A diferença é que no JavaScript não existe tipo decimal nativo. A proposta TC39 Decimal existe, mas ainda está em andamento. Então a solução, por enquanto, é usar uma lib que implemente isso.

Existem libs que fazem isso: decimal.js, big.js, bignumber.js. Todas resolvem a precisão. A diferença está no que vem junto. Para o contexto fiscal brasileiro, construí a tributos-br, que adiciona arredondamento HALF_UP por padrão (não precisa configurar), sete modos de arredondamento e calculadoras fiscais integradas (ICMS, IPI, DIFAL, ST):

import { Decimal } from 'tributos-br/precision'

const preco = Decimal.from('1.064')
const quantidade = Decimal.from('39680')
const total = preco.mul(quantidade).round(2)

console.log(total.toFixed(2)) // '42219.52' ← exato, sempre
Enter fullscreen mode Exit fullscreen mode

Sem conversão para IEEE 754 em nenhum momento. E os três casos que toFixed erra:

Decimal.from('1.255').round(2).toFixed(2)  // '1.26' ← HALF_UP correto
Decimal.from('2.675').round(2).toFixed(2)  // '2.68' ← HALF_UP correto
Decimal.from('1.005').round(2).toFixed(2)  // '1.01' ← HALF_UP correto
Enter fullscreen mode Exit fullscreen mode

O resultado não depende de sorte. É exato por construção.


Resumo

Três coisas pra levar deste artigo:

  1. O erro nasce na atribuição, não no cálculo. const preco = 1.064 já perdeu precisão antes de qualquer operação.

  2. toFixed, Math.round e centavos não são soluções. Operam sobre o float corrompido, ou esbarram nos limites de precisão da NF-e.

  3. Aritmética em strings elimina o problema na raiz. Sem conversão para binário, sem drift, sem surpresas.

Se você emite NF-e e quer testar: npm install tributos-br. Zero dependências, TypeScript strict, código aberto.

Esse é o primeiro post da série Precisão Numérica em TypeScript. No próximo, vou cobrir DIFAL e o problema da base dupla vs base única. Se quiser acompanhar, segue o perfil aqui no DEV.

Você já enfrentou problemas de arredondamento em produção? Em qual contexto? Conta nos comentários.


Este artigo teve assistência de IA na revisão e estruturação. Todo código e dados técnicos foram verificados manualmente.

Top comments (0)