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.
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.
// 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 integerA 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.
// 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 negativeDois 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.

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.