Aritmética de dinero en céntimos enteros, y el descuento que desapareció
Aplicamos un descuento de cincuenta céntimos y el libro mayor no registró ninguno. El culpable ni siquiera era la coma flotante. Una guía práctica para hacer cuentas de dinero con enteros: acotar, prorratear y conseguir que un libro de precios unitarios sume exactamente el total.
Todo desarrollador aprende pronto que 0,1 más 0,2 no es 0,3, así que todos guardamos el dinero en céntimos enteros y nos quedamos tranquilos. Luego construyes algo real, como un descuento porcentual sobre un carrito, y descubres que los enteros solo resuelven el primer problema. El segundo es que a la división le da igual tu contabilidad.
Los cincuenta céntimos que se esfumaron
Este es el caso que nos mordió, reducido a los huesos. Un carrito con 100 unidades de un artículo de un euro. El cliente se lleva cincuenta céntimos de descuento en el pedido. El subtotal son 10.000 céntimos, el descuento son 50 céntimos y el total con descuento son 9.950 céntimos. Hasta aquí, solo enteros.
Ahora registra esa venta en un libro mayor que guarda un precio unitario por línea, como les gusta a los sistemas contables. El precio unitario con descuento es 9.950 dividido entre 100, que son 99,5 céntimos. Eso no es un entero, así que se redondea a 100. El libro dice ahora 100 unidades a 100 céntimos: una venta a precio completo. El descuento no encogió. Desapareció, mientras la interfaz le prometía al cliente 9.950 y los libros decían 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 integerPartir la línea parece un capricho hasta que ves lo que compra. Cincuenta unidades a 100 céntimos más cincuenta unidades a 99 céntimos suman exactamente 9.950. Cada fila sigue guardando un precio unitario entero y honesto, las cantidades siguen cuadrando y la suma del libro coincide con el número del recibo hasta el céntimo. Nada de eso es posible con un único precio redondeado.
El prorrateo necesita una pasada de cuadre
La misma enfermedad aparece un nivel más arriba cuando un descuento de pedido se reparte entre varias líneas. Dale a cada línea su parte proporcional y redondea, y las partes sumarán casi el descuento. A veces un céntimo de más, a veces uno de menos y de vez en cuando justo, que es el peor resultado de todos porque esconde el fallo en cualquier prueba superficial.
// 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 negativeDos detalles de ese bucle se ganaron su sitio por las malas. El sobrante se reparte entre cualquier línea con capacidad, y no solo entre la última, porque la última línea puede ser un artículo de seis céntimos sin margen para absorber tres céntimos sobrantes. Y cada parte está acotada al subtotal de su propia línea, porque un descuento mayor que la línea implica un precio unitario negativo, y los precios unitarios negativos son la manera en la que un libro mayor empieza a mentir en la otra dirección.

Acota en cada extremo
- Los descuentos se acotan al rango entre cero y el subtotal. Un ciento diez por ciento de descuento es una errata, no una devolución.
- Interpreta con desconfianza los importes que escribe el usuario: vacío y no numérico significan cero, nunca un NaN a mitad de una operación.
- Las cantidades se acotan a lo que existe. El código de dinero hereda todos los fallos de inventario que tiene por encima.
- Prueba los totales que no se dividen exactos. Tres artículos de diez euros con un euro de descuento es mejor prueba que cualquier número redondo.
Nada de esto es glamuroso, que es justo por lo que se cuela en las revisiones. La aritmética parece correcta porque lo es, hasta que la división entera tira sin hacer ruido un resto que alguien tenía a su favor. Escribe el invariante, la suma de las partes es igual al todo, y pon una prueba a sujetar la puerta.
Los enteros mantienen honesto el dinero. El cuadre mantiene honestos a los enteros.