Programação Por Equipe DevBras

Capa do artigo: Dinheiro não mora em número de ponto flutuante

Dinheiro não mora em número de ponto flutuante

Guarde valores como inteiros em centavos ou em DECIMAL, faça contas com regra de arredondamento explícita, divida parcelas sem perder centavo e formate só na tela.

O sistema somou dez itens de R$ 0,10 e gravou R$ 0,9999999. A tela arredondou e mostrou R$ 1,00. O banco não arredondou, o boleto saiu com um centavo a menos, e a conciliação do fim do mês não fechou. A discussão com o cliente começa nesse centavo. A resposta curta: nunca guarde nem calcule dinheiro em float ou double. Guarde inteiros na menor unidade da moeda, centavos no caso do real, ou use um tipo decimal exato como DECIMAL(12,2). Faça as contas nessa unidade, com regra de arredondamento escrita, e formate com "R$" e vírgula só na hora de mostrar.

Por que 0,1 + 0,2 não dá 0,3

Números de ponto flutuante, o Number do JavaScript, o float do PHP e o DOUBLE do MySQL, guardam valores em base 2. Assim como 1/3 não tem representação exata em decimal (0,333...), 0,1 não tem representação exata em binário. O computador guarda o valor mais próximo possível, e o erro aparece quando você soma:

0.1 + 0.2              // 0.30000000000000004

let total = 0;
for (let i = 0; i < 10; i++) total += 0.1;
total                  // 0.9999999999999999

1.005 * 100            // 100.49999999999999
Math.round(1.005 * 100) // 100, e não 101

Cada erro é minúsculo, mas dinheiro não tolera erro nenhum. Soma de milhares de itens acumula diferença, uma comparação total === 1 falha, e o arredondamento no lugar errado transforma R$ 1,005 em R$ 1,00. Pior: o resultado depende da ordem das operações, então o relatório que soma por dia e o que soma por mês podem discordar.

Inteiros não têm esse problema. 10 vezes 10 centavos dá 100 centavos, sempre. Por isso gateways de pagamento como o Stripe recebem valores em centavos na API: R$ 19,99 vai como 1999.

Como guardar no banco

Duas opções corretas:

  • Inteiro em centavos, numa coluna BIGINT com nome que deixa a unidade clara, como total_centavos. Simples, rápido e idêntico ao que o código usa.
  • Decimal exato, DECIMAL(12,2) no MySQL ou numeric(12,2) no PostgreSQL. O banco faz soma e média sem erro, e o valor fica legível em consultas manuais.

Se a regra de negócio precisa de frações de centavo, como preço unitário de combustível ou câmbio, use mais casas decimais ou uma unidade menor, e arredonde para centavos só no total.

No PostgreSQL, evite o tipo money. A wiki do próprio projeto desaconselha: ele não guarda qual é a moeda, assume a da configuração de localidade do banco, e não lida com frações de centavo. Trocar a localidade do servidor muda a interpretação dos valores.

Um detalhe na leitura: drivers como o mysql2 e o pg, do Node, costumam devolver DECIMAL e numeric como texto, justamente para não perder precisão. Não resolva isso com parseFloat. Converta para centavos com uma função de texto, como a de entrada mostrada mais abaixo, ou trabalhe com a coluna em centavos desde o começo.

Como fazer as contas em centavos

  1. Converta a entrada do usuário de texto direto para centavos, sem passar por float.
  2. Faça somas e subtrações com inteiros.
  3. Em percentuais, multiplique primeiro e divida depois, arredondando uma única vez, com regra definida.
  4. Em divisões, como parcelas e rateios, distribua o resto para que a soma das partes feche com o total.
  5. Formate com Intl.NumberFormat só na exibição.
  6. Escreva testes com os valores chatos: 0,10, 1/3, 1,005 e totais que não dividem certo.
function reaisParaCentavos(texto) {
  const limpo = String(texto).replace(/R\$|\s|\./g, '');
  const partes = /^(\d+)(?:,(\d{1,2}))?$/.exec(limpo);
  if (!partes) return null;
  const centavos = Number(partes[1]) * 100 + Number((partes[2] ?? '0').padEnd(2, '0'));
  return Number.isSafeInteger(centavos) ? centavos : null;
}

function aplicarPercentual(centavos, pontosBase) {
  // pontosBase: 1250 significa 12,50%
  return Math.round((centavos * pontosBase) / 10000);
}

function dividirEmParcelas(totalCentavos, parcelas) {
  const base = Math.floor(totalCentavos / parcelas);
  const resto = totalCentavos % parcelas;
  return Array.from({ length: parcelas }, (_, i) => base + (i < resto ? 1 : 0));
}

const formatador = new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' });
function formatarCentavos(centavos) {
  return formatador.format(centavos / 100);
}

reaisParaCentavos('R$ 1.234,5');  // 123450
aplicarPercentual(19990, 1250);   // 2499 (2498,75 arredondado)
dividirEmParcelas(10000, 3);      // [3334, 3333, 3333]
formatarCentavos(123450);         // "R$ 1.234,50"

Alguns pontos desse código merecem atenção:

  • A entrada aceita só o formato brasileiro. Pontos são removidos como separador de milhar, então "1.5" vira R$ 15,00. Se o campo pode receber outro formato, trate antes, e mostre o valor interpretado para a pessoa confirmar.
  • Percentual em pontos-base (centésimos de ponto percentual) mantém a taxa inteira. 12,5% como 0.125 volta a ser float.
  • Math.round arredonda meio para cima em positivos, mas Math.round(-2.5) dá -2. Em estornos e valores negativos, arredonde o valor absoluto e reaplique o sinal, ou use a regra que o financeiro definiu.
  • A divisão em parcelas fecha a conta. R$ 100,00 em três vira 33,34 + 33,33 + 33,33. Dividir 100 por 3 e arredondar cada parcela daria 33,33 três vezes, e o centavo sumiria.
  • O formatador usa um espaço não separável entre "R$" e o número. Um teste que compara com "R$ 1.234,50" digitado com espaço comum falha, e é melhor saber disso antes.
  • A divisão por 100 na formatação é aceitável porque é o último passo e o resultado não volta para nenhuma conta.

O limite seguro de inteiros no JavaScript é 9.007.199.254.740.991. Em centavos, são dezenas de trilhões de reais, folga suficiente para quase todo sistema. Se você soma volumes maiores ou multiplica valores grandes por taxas, use BigInt ou uma biblioteca decimal. No PHP de 64 bits, int tem limite parecido, e a extensão bcmath faz contas decimais com texto quando precisa de mais.

Regra de arredondamento é decisão de negócio

Arredondar meio para cima, meio para o par (o "arredondamento bancário") ou sempre para baixo dá resultados diferentes em escala. Juros, impostos e descontos podem ter regra própria, definida em contrato ou em norma. Pergunte ao financeiro qual é a regra, escreva no código num único lugar e cubra com teste. Isto é orientação técnica: a regra fiscal certa para o seu caso deve ser confirmada com contador ou profissional habilitado.

Na API, envie valores como inteiros em centavos, com o nome do campo dizendo isso, como total_centavos. Um JSON com 19.99 será lido como float do outro lado. Em operações de cobrança, junte isso a uma chave de idempotência, para um centavo certo não ser cobrado duas vezes.

Como migrar uma coluna float

Não altere o tipo da coluna em produção de uma vez. Faça a migração em dois passos: crie total_centavos BIGINT, preencha com ROUND(total * 100), passe a gravar nas duas colunas, troque a leitura para a nova e só depois remova a antiga. Antes de remover, compare as somas por mês nas duas colunas. Diferenças apontam registros que já estavam errados.

Erros comuns

  • Coluna FLOAT ou DOUBLE para valor. Somas e relatórios divergem por centavos, e a conciliação não fecha.
  • Converter a entrada com parseFloat. parseFloat('19,99') dá 19, porque para na vírgula. Trocando a vírgula por ponto, o valor ainda passa por binário e pode perder um centavo no arredondamento.
  • Arredondar em cada etapa. Arredondar o item, o subtotal e o total produz um terceiro número que ninguém consegue reproduzir.
  • Dividir parcelas sem distribuir o resto. A soma das parcelas fica diferente do total cobrado.
  • Gravar texto formatado, como "R$ 1.234,50". Não soma, não ordena, e quebra quando o formato muda.
  • Usar o tipo money do PostgreSQL. A interpretação depende da localidade do servidor.

Checklist

  • Nenhuma coluna de valor monetário é FLOAT, DOUBLE ou REAL.
  • Colunas em centavos têm a unidade no nome.
  • A entrada do usuário vira centavos sem passar por ponto flutuante.
  • Percentuais são aplicados com uma única regra de arredondamento, escrita num só lugar.
  • Parcelas e rateios somam exatamente o total.
  • A API troca valores como inteiros em centavos.
  • Existe teste com 0,10 somado dez vezes, 100 dividido por 3 e 1,005.

Rode agora, no seu banco, uma busca pelas colunas de tipo float, double, real ou double precision no information_schema.columns. Alguma delas guarda preço, total, desconto ou saldo?

Avalie este artigo

Referência: https://wiki.postgresql.org/wiki/Don%27t_Do_This#Don.27t_use_money