Blog

Por que dinheiro nunca deve ser float.

Abra o console do navegador e digite 0.1 + 0.2.

> 0.1 + 0.2
0.30000000000000004

Todo dev já viu isso. Quase todo mundo trata como curiosidade de linguagem, dá risada e segue. Depois usa float numa coluna de valor, e a curiosidade vira uma diferença de R$ 340 no fechamento do mês que ninguém consegue explicar.

O que está acontecendo de verdade

Ponto flutuante binário representa números em potências de dois. Um décimo, em binário, é uma dízima periódica infinita, do mesmo jeito que um terço é infinito em decimal.

Como o espaço é finito, o valor guardado é o mais próximo possível de 0.1, não 0.1. O erro é minúsculo. Ele também é acumulativo.

let total = 0;
for (let i = 0; i < 10000; i++) total += 0.01;
console.log(total); // 100.00000000000144

Dez mil operações de um centavo e você já perdeu o controle da segunda casa. Um sistema de pagamento faz isso antes do café.

O detalhe cruel é que o erro não aparece na tela. Você formata com duas casas na hora de exibir, tudo parece certo, e a divergência só se manifesta quando alguém compara dois totais calculados por caminhos diferentes.

A regra: inteiro na menor unidade

Guarde centavos. Um número inteiro.

// errado
const price: number = 19.9;

// certo
const priceMinor: bigint = 1990n; // centavos

No banco, bigint, não float nem double precision.

-- não
amount numeric(10,2)   -- melhor que float, mas ainda convida a erro na app
amount double precision -- nunca

-- sim
amount_minor bigint not null

numeric do Postgres é decimal exato e não sofre o problema do binário. Ele funciona. O motivo de eu preferir inteiro em centavos mesmo assim é que o valor atravessa camadas: sai do banco, vira JSON, entra no JavaScript, volta. E JavaScript converte numeric pra number, que é float de 64 bits. O ganho do banco evapora no primeiro JSON.parse.

Inteiro sobrevive à viagem inteira.

O limite do inteiro em JavaScript

Number.MAX_SAFE_INTEGER é 9.007.199.254.740.991. Em centavos, isso é uns 90 trilhões de reais. Parece confortável até você trabalhar com moeda de baixo valor unitário, ou com produto que soma volume acumulado ao longo de anos.

Se existe qualquer chance de passar disso, use BigInt no runtime e string no transporte:

{ "amount_minor": "1990", "currency": "BRL" }

String no JSON parece feio e é o que evita que um parser distraído transforme seu valor em float no meio do caminho. Stripe faz assim há anos, e não é por falta de opção.

Divisão é onde some o centavo

Guardar inteiro resolve soma e subtração. Divisão continua sendo decisão de negócio.

// R$ 100 dividido entre três vendedores
const total = 10000n;
const parts = 3n;
const each = total / parts; // 3333n
// 3333 * 3 = 9999. Sobrou 1 centavo.

Esse centavo tem que ir pra algum lugar explícito. As três políticas usadas na prática:

Sobra para o primeiro da lista. Simples, determinística, e favorece sempre o mesmo, o que pode ser injusto em repartição recorrente.

Sobra para a plataforma, numa conta de ajuste de arredondamento. Minha preferida, porque fica auditável.

Distribuição por maior resto, o método que eleição usa. Mais justa e mais código.

Qualquer uma serve. O que não serve é deixar implícito, porque implícito significa que o centavo some e a verificação de balanceamento do ledger começa a acusar transação desbalanceada.

Câmbio multiplica o problema

Converter moeda envolve taxa com muitas casas decimais. Aqui inteiro puro não basta.

O padrão que funciona: guarde a taxa como inteiro com escala explícita e declare em que ponto o arredondamento acontece.

// taxa 5.4321 guardada como 54321 com escala 4
const rateScaled = 54321n;
const scale = 10000n;

const converted = (amountMinor * rateScaled) / scale; // trunca no fim

Multiplique primeiro, divida depois. Inverter a ordem perde precisão antes da multiplicação, e o erro fica bem maior.

E grave os três valores: original, taxa usada e resultado. Sem a taxa registrada, ninguém consegue reproduzir a conversão seis meses depois, e reproduzir é exatamente o que a auditoria vai pedir.

Como achar isso num sistema existente

select table_name, column_name, data_type
from information_schema.columns
where data_type in ('real', 'double precision')
  and (column_name ~* 'amount|price|value|total|balance|fee|valor|preco|saldo');

Se voltar linha, você tem trabalho pela frente. A migração é chata mas mecânica: coluna nova em bigint, backfill com arredondamento documentado, escrita dupla por um período, leitura migrada, coluna antiga derrubada.

O trabalho chato é agora. O outro trabalho, o de explicar pro cliente por que o extrato dele não bate, vem depois e é bem pior.

← Voltar pro blog