Diário
Engenharia4 min de leitura

Contas de dinheiro em cêntimos inteiros, e o desconto que desapareceu

Demos um desconto de cinquenta cêntimos e o registo não guardou nenhum. A vírgula flutuante nem sequer foi a culpada. Um guia de campo para fazer contas de dinheiro com inteiros: limites, rateio, e como fazer com que um registo de preços unitários some exatamente o total.

AEA equipa de engenharia do Bookatu

Todos os programadores aprendem cedo que 0,1 mais 0,2 não dá 0,3, por isso guardamos todos o dinheiro em cêntimos inteiros e ficamos descansados. Depois constrói-se uma coisa a sério, como um desconto em percentagem sobre um carrinho, e descobre-se que os inteiros só resolvem o primeiro problema. O segundo problema é que a divisão não quer saber do seu registo.

Os cinquenta cêntimos que desapareceram

Eis a situação que nos apanhou, reduzida ao osso. Um carrinho tem 100 unidades de um artigo de um dólar. O cliente recebe cinquenta cêntimos de desconto na encomenda. O subtotal é 10 000 cêntimos, o desconto é 50 cêntimos, e o total com desconto é 9950 cêntimos. Até aqui, só inteiros.

Agora registe essa venda num livro que guarda um preço unitário por linha, como os sistemas de contabilidade gostam. O preço unitário com desconto é 9950 a dividir por 100, ou seja 99,5 cêntimos. Isso não é um inteiro, por isso é arredondado para 100. O registo passa a dizer 100 unidades a 100 cêntimos: uma venda a preço cheio. O desconto não encolheu. Desapareceu, enquanto a interface prometia 9950 ao cliente e as contas diziam 10 000.

js
// The trap: one rounded unit price cannot represent every total.const subtotal = 100 * 100;              // 10000cconst discounted = subtotal - 50;        // 9950cconst unit = Math.round(discounted / 100); // 100c ... the discount is goneconsole.log(unit * 100);                 // 10000c, not 9950c // The fix: split the line into two prices one cent apart.const qty = 100;const base = Math.floor(discounted / qty);   // 99cconst rem = discounted - base * qty;         // 50 units get one extra cent// rem units at (base + 1), the rest at base:// 50 * 100 + 50 * 99 === 9950  ✓ exact, and every price is an integer

A divisão parece um preciosismo até se ver o que traz. Cinquenta unidades a 100 cêntimos mais cinquenta unidades a 99 cêntimos dá exatamente 9950. Cada linha continua a guardar um preço unitário inteiro e honesto, as quantidades continuam a bater certo, e a soma do registo iguala ao cêntimo o número do recibo. Nada disso é possível com um único preço arredondado.

O rateio precisa de uma passagem de fecho

A mesma doença aparece um nível acima quando um desconto ao nível da encomenda se espalha por várias linhas. Dê a cada linha a parte proporcional e arredonde, e as partes vão somar quase o desconto. Às vezes um cêntimo a mais, às vezes um a menos, de vez em quando certinho, que é o pior resultado de todos porque esconde o erro de quem testa por alto.

js
// Prorate by share, then push the rounding leftover onto lines with room.let allocated = 0;for (const line of lines) {  line.discount = Math.min(    line.subtotal,    Math.round((discount * line.subtotal) / orderSubtotal),  );  allocated += line.discount;}let remainder = discount - allocated; // may be negativefor (const line of lines) {  if (remainder === 0) break;  const room = remainder > 0 ? line.subtotal - line.discount : line.discount;  const move = Math.min(Math.abs(remainder), room) * Math.sign(remainder);  line.discount += move;  remainder -= move;}// invariant: sum(line.discount) === discount, and no line goes negative

Dois pormenores desse ciclo ganharam o seu lugar à força. O resto espalha-se por qualquer linha com capacidade, e não só pela última linha, porque a última linha pode ser um artigo de seis cêntimos sem folga para absorver três cêntimos que sobraram. E cada parte é limitada ao subtotal da própria linha, porque um desconto maior do que a linha implica um preço unitário negativo, e os preços unitários negativos são a forma de um registo começar a mentir no sentido contrário.

Um ecrã de ponto de venda com um subtotal, um desconto de dez por cento e o total exato com desconto
O recibo aborrecido é o objetivo todo: subtotal, desconto e um total que o registo reproduz ao cêntimo.

Limite em todas as pontas

  • Os descontos ficam limitados ao intervalo entre zero e o subtotal. Cento e dez por cento de desconto é uma gralha, não um reembolso.
  • Leia os valores introduzidos pelo utilizador com desconfiança: vazio e não numérico querem dizer zero, nunca NaN a meio de uma conta.
  • As quantidades ficam limitadas ao que existe. O código do dinheiro herda todos os erros de inventário que vêm antes dele.
  • Teste os totais que não dividem de forma exata. Três artigos a dez dólares com um dólar de desconto é um teste melhor do que qualquer número redondo.

Nada disto é glamoroso, e é exatamente por isso que passa na revisão. A aritmética parece certa porque está certa, até ao momento em que a divisão inteira deita fora em silêncio um resto a que alguém tinha direito. Escreva a invariante num papel, a soma das partes é igual ao todo, e ponha um teste a segurar a porta.

Os inteiros mantêm o dinheiro honesto. O fecho mantém os inteiros honestos.

dinheiro em cêntimosvírgula flutuante e moedarateio de descontoserros de arredondamentoengenharia de pagamentos
Pronto para pôr isto em prática?

O Bookatu dá-lhe um site de marcações com a sua marca, sinais, subscrições, vales-presente e lembretes, com 0% de comissão nas suas marcações.

Começar grátis