Geldrechnen in ganzen Cent, und der Rabatt, der verschwand
Wir haben fünfzig Cent Rabatt gegeben, und das Buch hat davon nichts verzeichnet. Fließkomma war nicht einmal der Täter. Ein Leitfaden für Geldarithmetik mit ganzen Zahlen: begrenzen, anteilig verteilen und ein Buch aus Stückpreisen, das genau die Summe ergibt.
Jeder Entwickler lernt früh, dass 0,1 plus 0,2 nicht 0,3 ergibt, also speichern wir Geld alle in ganzen Cent und fühlen uns sicher. Dann baut man etwas Echtes, etwa einen prozentualen Rabatt über einen ganzen Warenkorb, und stellt fest, dass ganze Zahlen nur das erste Problem lösen. Das zweite Problem ist, dass sich die Division für dein Buch nicht interessiert.
Die fünfzig Cent, die verschwanden
Das ist der Fall, der uns erwischt hat, auf die Knochen reduziert. Ein Warenkorb enthält 100 Stück eines Artikels für einen Dollar. Der Kunde bekommt fünfzig Cent auf die Bestellung. Die Zwischensumme sind 10.000 Cent, der Rabatt 50 Cent, und die rabattierte Summe 9.950 Cent. Bis hierhin nur ganze Zahlen.
Jetzt verbuch diesen Verkauf in einem Buch, das pro Zeile einen Stückpreis speichert, so wie Buchhaltungssysteme das gern haben. Der rabattierte Stückpreis ist 9.950 geteilt durch 100, also 99,5 Cent. Das ist keine ganze Zahl, also wird auf 100 gerundet. Im Buch stehen jetzt 100 Stück zu 100 Cent: ein Verkauf zum vollen Preis. Der Rabatt ist nicht geschrumpft. Er ist verschwunden, während die Oberfläche dem Kunden 9.950 versprochen hat und die Bücher 10.000 sagten.
// 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 integerDie Aufteilung wirkt pingelig, bis man sieht, was sie einbringt. Fünfzig Stück zu 100 Cent plus fünfzig Stück zu 99 Cent ergeben genau 9.950. Jede Zeile speichert weiter einen ehrlichen ganzzahligen Stückpreis, die Mengen gehen weiter auf, und die Summe des Buchs entspricht auf den Cent der Zahl auf dem Beleg. Mit einem einzigen gerundeten Preis ist davon nichts möglich.
Das anteilige Verteilen braucht einen Abgleich
Dieselbe Krankheit taucht eine Ebene höher auf, wenn sich ein Rabatt auf die ganze Bestellung über mehrere Zeilen verteilt. Gib jeder Zeile ihren proportionalen Anteil und runde, und die Anteile ergeben fast den Rabatt. Mal einen Cent zu viel, mal einen zu wenig, gelegentlich genau richtig, und das ist der schlimmste Ausgang, weil er den Fehler vor beiläufigen Tests versteckt.
// 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 negativeZwei Details in dieser Schleife haben sich auf die harte Tour verdient gemacht. Der Rest verteilt sich auf jede Zeile mit Kapazität und nicht nur auf die letzte, denn die letzte Zeile kann ein Artikel für sechs Cent sein, der drei übrige Cent nicht aufnehmen kann. Und jeder Anteil ist auf die eigene Zwischensumme der Zeile gedeckelt, denn ein Rabatt, der größer ist als die Zeile, bedeutet einen negativen Stückpreis, und negative Stückpreise sind der Weg, auf dem ein Buch in die andere Richtung zu lügen beginnt.

Begrenz jeden Rand
- Rabatte werden auf den Bereich von null bis zur Zwischensumme begrenzt. Hundertzehn Prozent Nachlass sind ein Tippfehler, keine Erstattung.
- Lies eingegebene Beträge defensiv ein: leer und nicht numerisch bedeuten null, niemals NaN mitten in der Rechnung.
- Mengen werden auf das begrenzt, was es gibt. Geld-Code erbt jeden Bestandsfehler, der vor ihm liegt.
- Test die Summen, die nicht glatt aufgehen. Drei Artikel zu zehn Dollar mit einem Dollar Nachlass ist ein besserer Test als jede runde Zahl.
Nichts davon ist glamourös, und genau deshalb rutscht es durchs Review. Die Arithmetik sieht richtig aus, weil sie richtig ist, bis die ganzzahlige Division still einen Rest wegwirft, der jemandem zustand. Schreib die Invariante auf, Summe der Teile gleich Ganzes, und lass einen Test die Tür halten.
Ganze Zahlen halten das Geld ehrlich. Der Abgleich hält die ganzen Zahlen ehrlich.