Journal
Technique4 min de lecture

Compter l'argent en centimes entiers, et la remise qui a disparu

Nous avons accordé une remise de cinquante centimes et le grand livre n'en a rien retenu. La virgule flottante n'était même pas la coupable. Guide de terrain de l'arithmétique monétaire en entiers : bornage, répartition au prorata, et comment faire tomber un grand livre de prix unitaires exactement sur le total.

LTL'équipe technique de Bookatu

Tout développeur apprend très tôt que 0,1 plus 0,2 ne fait pas 0,3, alors nous stockons tous l'argent en centimes entiers et nous nous sentons à l'abri. Puis vous construisez quelque chose de réel, comme une remise en pourcentage sur un panier, et vous découvrez que les entiers ne règlent que le premier problème. Le second, c'est que la division se moque de votre grand livre.

Les cinquante centimes évaporés

Voici la situation qui nous a mordus, réduite à l'os. Un panier contient 100 unités d'un article à un dollar. Le client obtient cinquante centimes de remise sur la commande. Le sous-total est de 10 000 centimes, la remise de 50 centimes, et le total remisé de 9 950 centimes. Jusqu'ici, rien que des entiers.

Enregistrez maintenant cette vente dans un grand livre qui stocke un prix unitaire par ligne, comme les systèmes comptables aiment le faire. Le prix unitaire remisé, c'est 9 950 divisé par 100, soit 99,5 centimes. Ce n'est pas un entier, il est donc arrondi à 100. Le grand livre dit maintenant 100 unités à 100 centimes : une vente au prix fort. La remise n'a pas rétréci. Elle a disparu, pendant que l'interface promettait 9 950 au client et que les livres disaient 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

Le découpage a l'air tatillon jusqu'à ce qu'on voie ce qu'il apporte. Cinquante unités à 100 centimes plus cinquante unités à 99 centimes font exactement 9 950. Chaque ligne stocke toujours un prix unitaire entier et honnête, les quantités s'additionnent toujours, et la somme du grand livre est égale au centime près au chiffre du reçu. Rien de tout cela n'est possible avec un seul prix arrondi.

La répartition au prorata a besoin d'une passe de réconciliation

La même maladie apparaît un cran au-dessus quand une remise au niveau de la commande se répartit sur plusieurs lignes. Donnez à chaque ligne sa part proportionnelle et arrondissez, et les parts feront presque la remise. Parfois un centime de trop, parfois un de moins, de temps en temps pile juste, ce qui est le pire des cas parce que cela cache le bug à un test rapide.

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

Deux détails de cette boucle ont gagné leur place à la dure. Le reliquat se répartit sur n'importe quelle ligne qui a de la capacité, et pas seulement sur la dernière, parce que la dernière ligne peut être un article à six centimes sans la place d'absorber trois centimes restants. Et chaque part est plafonnée au sous-total de sa propre ligne, parce qu'une remise plus grande que la ligne implique un prix unitaire négatif, et les prix unitaires négatifs sont la façon dont un grand livre se met à mentir dans l'autre sens.

Un écran de caisse affichant un sous-total, une remise de dix pour cent et le total remisé exact
Le reçu ennuyeux est tout l'enjeu : un sous-total, une remise, et un total que le grand livre reproduit au centime près.

Bornez à chaque extrémité

  • Les remises se bornent entre zéro et le sous-total. Cent dix pour cent de remise, c'est une faute de frappe, pas un remboursement.
  • Lisez les montants saisis par l'utilisateur avec méfiance : vide et non numérique valent zéro, jamais NaN au milieu d'un calcul.
  • Les quantités se bornent à ce qui existe. Le code de l'argent hérite de tous les bugs de stock en amont.
  • Testez les totaux qui ne tombent pas juste. Trois articles à dix dollars avec un dollar de remise est un meilleur test que n'importe quel chiffre rond.

Rien de tout cela n'est glorieux, et c'est exactement pour ça que ça passe à travers la relecture. L'arithmétique a l'air correcte parce qu'elle est correcte, jusqu'au moment où la division entière jette discrètement un reste que quelqu'un attendait. Écrivez l'invariant noir sur blanc, la somme des parties est égale au tout, et laissez un test tenir la porte.

Les entiers gardent l'argent honnête. La réconciliation garde les entiers honnêtes.

argent en centimesvirgule flottante et monnaierépartition d'une remiseerreurs d'arrondiingénierie des paiements
Envie de mettre tout ça en pratique ?

Bookatu vous donne une page de réservation à votre image, des acomptes, des abonnements, des cartes cadeaux et des rappels, avec 0 % de commission sur vos réservations.

Commencer gratuitement