Beløb regnet i hele cent, og rabatten der forsvandt
Vi gav halvtreds cents rabat, og regnskabet noterede ingenting. Flydende tal var ikke engang synderen. En feltguide til at regne med penge i heltal: afgrænsning, fordeling og at få et regnskab af enhedspriser til at summe til den præcise total.
Alle udviklere lærer tidligt, at 0,1 plus 0,2 ikke er 0,3, så vi gemmer alle sammen beløb som hele cent og føler os trygge. Så bygger man noget rigtigt, for eksempel en procentrabat på tværs af en kurv, og opdager, at heltal kun løser det første problem. Det andet problem er, at division er ligeglad med dit regnskab.
De halvtreds cent, der forsvandt
Her er den opstilling, der bed os, skåret ind til benet. En kurv indeholder 100 stk. af en vare til én dollar. Kunden får halvtreds cents rabat på ordren. Subtotalen er 10.000 cent, rabatten er 50 cent, og den rabatterede total er 9.950 cent. Indtil videre kun heltal.
Bogfør så det salg i et regnskab, der gemmer en enhedspris pr. linje, sådan som regnskabssystemer godt kan lide. Den rabatterede enhedspris er 9.950 divideret med 100, altså 99,5 cent. Det er ikke et heltal, så det bliver rundet op til 100. Regnskabet siger nu 100 stk. til 100 cent: et salg til fuld pris. Rabatten blev ikke mindre. Den forsvandt, mens brugerfladen lovede kunden 9.950, og bøgerne sagde 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 integerOpdelingen ser pillen ud, indtil man ser, hvad den køber. Halvtreds stk. til 100 cent plus halvtreds stk. til 99 cent giver præcis 9.950. Hver række gemmer stadig en ærlig enhedspris i heltal, antallene går stadig op, og summen af regnskabet svarer til tallet på kvitteringen ned til sidste cent. Intet af det er muligt med én afrundet pris.
Fordeling kræver en afstemning bagefter
Den samme sygdom dukker op et niveau højere oppe, når en rabat på hele ordren fordeles over flere linjer. Giv hver linje sin forholdsmæssige andel og rund af, og andelene vil summe til næsten rabatten. Nogle gange én cent over, nogle gange én under, en gang imellem lige præcis, hvilket er det værste udfald af dem alle, fordi det skjuler fejlen for overfladiske test.
// 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 negativeTo detaljer i den løkke har gjort sig fortjent på den hårde måde. Resten fordeles på enhver linje med plads, ikke bare den sidste linje, fordi den sidste linje kan være en vare til seks cent uden plads til at optage tre overskydende cent. Og hver andel bliver begrænset af sin egen linjesubtotal, fordi en rabat, der er større end linjen, betyder en negativ enhedspris, og negative enhedspriser er den måde, et regnskab begynder at lyve på i den anden retning.

Afgræns i hver kant
- Rabatter afgrænses til intervallet fra nul til subtotalen. Hundrede og ti procents rabat er en slåfejl, ikke en refusion.
- Læs indtastede beløb defensivt: tomt og ikke-numerisk betyder nul, aldrig NaN midt i et regnestykke.
- Antal afgrænses til det, der findes. Kode, der regner med penge, arver hver eneste lagerfejl længere oppe.
- Test de totaler, der ikke går lige op. Tre varer til ti dollars med en dollar i rabat er en bedre test end noget rundt tal.
Intet af det her er glamourøst, og det er præcis derfor, det slipper gennem review. Regnestykket ser rigtigt ud, fordi det er rigtigt, lige indtil heltalsdivision stille smider en rest væk, som nogen havde til gode. Skriv invarianten ned, summen af delene er lig med helheden, og lad en test holde døren.
Heltal holder pengene ærlige. Afstemning holder heltallene ærlige.