Abra o console do navegador e digite:
1.064 * 39680
// 42219.520000000004
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
- Rejeição 630: a regra que os ERPs esquecem
- O que não funciona
- Por que strings resolvem
- Resumo
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:
const preco = 1.064 // armazenado como ~1.0640000000000001
const qtd = 39680 // inteiro, representação exata
const total = preco * qtd // 42219.520000000004
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:
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'
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'
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
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
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'
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)
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 ✓
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.
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
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
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 }
A multiplicação opera sobre esses dígitos, como você faria no papel:
- Multiplica os dígitos inteiros:
1064 × 39680 = 42219520 - Soma os expoentes:
-3 + 0 = -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
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
O resultado não depende de sorte. É exato por construção.
Resumo
Três coisas pra levar deste artigo:
O erro nasce na atribuição, não no cálculo.
const preco = 1.064já perdeu precisão antes de qualquer operação.toFixed,Math.rounde centavos não são soluções. Operam sobre o float corrompido, ou esbarram nos limites de precisão da NF-e.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)