Cómo hacer una aplicación que soporte precios con decimales sin errores

Hace algunos días comentaba, en un artículo muy básico, cuáles eran las operaciones matemáticas o fórmulas para sacar y extraer el IVA de los importes. Sin embargo, manipular decimales, cuando se trata de precios, requiere seguir una serie de pautas muy importantes si no queremos encontrarnos con situaciones como la de la imagen.

Y no me refiero a usar simplemente el tipo de datos apropiado, sino a como gestionar los precios y manipularlos durante todo el flujo de la aplicación si queremos que estos sean coherentes durante todo su ciclo de vida: alta de productos, compras, descuentos, impuestos, etc.

La visión del artículo es funcional: hay poco código, pocos tecnicismos y muchas recomendaciones de negocio, principalmente. Si quieres literatura técnica, puedes ir directamente al final, donde hay algunos buenos enlaces y un FAQ propio. Mi idea no es ser un experto en coma flotante, si no cómo hacer una aplicación donde se trabaja con precios de una manera robusta y a prueba de errores de cálculo.

1 Tipo de datos

Olvídate de float y long. Solamente con que hagas un

1 System.out.println(0.1F+0.2F) // 0.30000000447034836

verás que contiene un minúsculo margen de error en la parte decimal. Es posible que desaparezca si redondeas, pero puede romper realmente tu aplicación. Ojo, esto no es un bug de Java, viene determinado por la naturaleza del tipo de datos decimal cuando es transformado a binario. Así que esto también sucede en tu base de datos si usas float (o double):

CREATE TABLE prueba (chungo float, suelto double);
INSERT INTO prueba VALUES (0.1, 0.1);
INSERT INTO prueba VALUES (0.2, 0.2);
SELECT SUM(chungo), SUM(suelto) FROM prueba;
| 0.30000000447034836 | 0.30000000000000004 |

La explicación de porqué sucede esto la tienes abajo en el FAQ.

Solución:

  • Usa en tus clases de dominio BigDecimal, el cual no tiene este problema. Es tratado como un número entero que contiene simplemente una coma en cierta posición, por lo que cualquier operación decimal realizada es exacta. El único problema es que los cálculos son más lentos (las operaciones las realiza la clase BigDecimal dentro de la JVM) que con primitivos int, long, float y double (las operaciones son realizadas por la CPU). Pero eso es algo con lo que podemos -y debemos- vivir.
  • Usa en tu base de datos el tipo DECIMAL(M, D). Donde M es el tamaño del número (parte entera y decimal) y D es el tamaño de la parte decimal solo. Así un tipo decimal(15, 2) nos permitirá guardar hasta 9999999999999.99

En Java no existe un tipo de datos primitivo para BigDecimal, por lo que todas las operaciones matemáticas realizadas con este tipo se deben hacer mediante métodos, con cosas tan barrocas como esta:

1 System.out.println(new BigDecimal("0.2").add(new BigDecimal("0.1"))); // -> 0.3

En Groovy no es necesario, ya que todas las operaciones en las que se ve involucrado un número decimal (float, double o BigDecimal) son realizadas con BigDecimal automáticamente.

1 // Código Groovy, decimales son BigDecimal por defecto
2 println(0.1 + 0.2) // -> 0.3

2 Como guardar los precios de los productos antes de venderlos

Pese a que en nuestra aplicación siempre vamos a mostrar todos los importes con solo dos decimales (redondeando además), nuestro modelo de datos debe guardar el importe de los productos siempre sin impuestos (sin IVA en España) y con tres decimales. Y el importe con IVA debe ser siempre calculado al extraerlo de base de datos. La pregunta es ¿pero para qué necesito tres decimales, si solo voy a mostrar dos? Veamos los casos posibles:

Que el administrador introduzca el precio del producto sin IVA directamente.
En este caso solo permitimos que teclee dos decimales, aunque guardemos luego tres (es decir, el tercer decimal será siempre 0). Ejemplo: el administrador introduce “11,95€”, por lo que guardamos 11.950. Para mostrar el precio en la web, simplemente leemos el importe sin IVA de base de datos y calculamos el importe con IVA: “11,950€ + IVA 21% = 14,46€” (el cálculo sería 11.95 * 1.21 = 14.4595, que redondeando a dos decimales da 14.46)

Que el administrador introduzca el precio del producto con IVA incluido.
¿Y por qué querría hacer tal cosa? Pues simplemente para asegurarse de que el producto se vende al público con un precio que a él le parece apropiado, por ejemplo “108,99€ IVA incluido”, y desea que la obtención de la base imponible sea calculada por el sistema (o sea nosotros). En este caso, nuestra aplicación recibe el número “108,99” y después le quita el IVA (para un IVA del 21% lo divide entre 1.21) y lo guarda en base de datos. Aquí es donde puede haber un problema: el sistema debría poder volver a aplicar el IVA al importe guardado en base de datos y obtener exactamente el mismo importe que el administrador había introducido. Y si guardamos el importe sin IVA con solo dos decimales, al volvérselo a añadir, el resultado puede ser distinto. Veamos un ejemplo (haciendo los cálculos con todos los decimales que disponemos, pero redondeando siempre a dos decimales al mostrarlos): el administrador desea que el importe de su producto sea de “108,99€ IVA incluido”.

Si usamos en base de datos dos decimales con tipo DECIMAL(15, 2), el resultado es incorrecto:

  • El administrador introduce “108,99€” IVA incluido.
  • Le quitamos el IVA: 108.99 / 1.21 = 90.07 y lo guardamos en base de datos con dos decimales.
  • Leemos de base de datos y le añadimos el IVA: 90.07 * 1.21 = 108.9847
  • Mostramos “90,07€ + IVA 21% = 108,98€” -> INCORRECTO! no coincide con los 108.99 del importe inicial.

Pero si usamos en base de datos tres decimales con tipo DECIMAL(16, 3) es correcto:

  • El administrador introduce “108,99€” IVA incluido.
  • Le quitamos el IVA: 108.99 / 1.21 = 90.074 y lo guardamos en base de datos con tres decimales.
  • Leemos de base de datos y le añadimos el IVA: 90.074 * 1.21 = 108.98954
  • Mostramos “90,07€ + IVA 21% = 108,99€” -> CORRECTO! si coincide con el importe inicial.

Resumen: para conservar un número que tiene N decimales al multiplicarlo y dividirlo por otro, debemos usar N+1 decimales. En nuestro caso, al ser precios en euros, nuestros números tienen dos decimales, por lo que si queremos quitar el IVA y volvérselo añadir, y obtener el mismo número, debemos usar tres decimales: DECIMAL(15, 3)

3 Como guardar los precios de las compras

Al comprar un producto se deben hacer dos cosas:

  1. Primero se realizan todos los cálculos y después se guardan en base de datos. Esto incluye el IVA (y descuentos aplicados), tanto el tipo (por ejemplo 21% para el IVA) como la cantidad. El hecho de guardar todos los importes parciales y tipos usados nos previene ante posibles cambios de IVA y nos evita hacer cálculos cada vez que necesitemos generar una factura o informe. Y también nos ayuda a hacer sumatorios en base de datos de estos parciales. Por ejemplo, si necesitamos saber el importe total de todo el IVA cobrado a todos los usuarios en sus compras.
  2. Todos los importes guardados tendrán solamente DOS decimales y serán los importes que han sido mostrados al usuario (y, por lo tanto, previamente redondeados). Esto debe ser así porque no importa que el cálculo del total a cobrar al usuario haya sido 85.431€, si al usuario le estamos mostrando “85,43€”, se le deben cobrar 85,43€ en la pasarela de pago y se debe almacenar en la base de datos 85.43. Si no se hiciera así y guardásemos 85.431, una suma de todas las compras del usuario podría dar un importe superior al acumular decimales en todas las compras. Si se almacena el número ya redondeado y con dos decimales, esto nunca sucede.

Siguiendo el ejemplo anterior: un usuario compra un libro por “90,07€” (que, recordemos, se guarda en base de datos con tres decimales como 90.074). En nuestra tabla de compras debemos guardar una foto de todos los importes en ese preciso momento con dos decimales ya redondeados:

 base imponible = 90.07    // El importe del producto con 3 decimales redondeado a 2 decimales
 tipo IVA       = 0.21     // Constante configurable
 importe final  = 108.99   // 90.074 * 1.21 = 108.98954 redondeado a 2 decimales
 IVA            = 18.92    // 108.99 - 90.07 no necesita redondear

Ahora podemos obtener las compras de un usuario y hacer sumas totales con dos decimales sin temor a perder exactitud, porque una vez cobrado el importe, ya no hace falta conservar más decimales: los precios se quedan congelados en la tabla para saber que se cobró, que IVA se aplicó (cantidad y tipo), etc. En cualquier momento podemos volver a generar una factura o extraer un informe con esta información, sin necesidad de hacer nuevos cálculos, pues ya han sido precalculados y cobrados, por lo que son inmutables.

Resumen

Siguiendo estas tres reglas protegeremos nuestra aplicación:

  1. Evitando datos incorrectos producidos por la inexactitud de los tipos en float y double gracias al uso de BigDecimal en nuestras clases de dominio y al tipo DECIMAL(M, D) en nuestra base de datos.
  2. Para que al extraer y después añadir el IVA al precio de un producto obtengamos después exactamente el mismo importe debemos guardar en base de datos los importes con tres decimales (regla de conservación de importes: para N decimales, guadar N+1)
  3. Para poder obtener una factura o informe con exactamente los mismos datos que han sido cobrados en su momento al usuario, no tenemos que usar tres decimales: debemos guardar todos los importes parciales y totales ya calculados, pero con dos decimales. Tambien hay que guardar el tipo de IVA utilizado (18, 21, etc).

Errores comunes

  • Utilizar 4 (¡o 5, 6 o 10!) decimales para guardar los importes. No por usar muchos decimales hace que los cálculos sean más exactos. Si necesitamos una precisión de 2 decimales, con 3 es suficiente. Por otro lado, las compras ya realizadas se deben guardar con 2 decimales, o un sumatorio de todas las compras puede dar un importe superior. La regla de oro es: en una compra, lo que se ha cobrado y lo que se ha mostrado al usuario, es lo que se guarda.
  • Utilizar enteros y guardar los importes en céntimos. Esto requiere dividir entre 100 al formatear todos los números antes de imprimirlos. Y que cuando se multiplican o dividen centimos, hay que desplazar la coma. Todas estas operaciones añaden complejidad a la aplicación y son fuentes de errores. Para hacer esto, mejor usar BigDecimal que nos evita este trabajo. Más info sobre trabajar con céntimos: http://floating-point-gui.de/formats/integer/
  • Hacer cosas raras para redondear como multiplicar entre 100, truncar decimales (casting a int) y dividir entre 100 de nuevo. No es necesario, los BigDecimal se redondean el método setScale(decimales). Por ejemplo, para redondear a 2 decimales se hace: new BigDecimal(“0.166”).setScale(2, RoundingMode.HALF_UP). Más info sobre los tipos de redondeo posibles: http://docs.oracle.com/javase/1.5.0/docs/api/java/math/RoundingMode.html
  • Crear números BigDecimal usando float o double. En vez de usar new BigDecimal(0.1), hay que usar new BigDecimal(“0.1”), no sea que suceda esto: http://stackoverflow.com/questions/9795364/java-bigdecimal-precision-problems.
  • Me comenta @jerolba en uno de los comentarios que para comparar dos BigDecimal hay que usar compareTo() en vez de equals(). El método equals() tiene en cuenta el nº de decimales, y por mucho que cueste creerlo, 1 no es igual a 1.00. El siguiente assert no pasa: assertTrue(new BigDecimal(“1″).equals(new BigDecimal(“1.00″))); Cuando queráis saber si dos BigDecimal son iguales es mejor usar el método compareTo y mirar si su valor es 0: assertEquals(0, new BigDecimal(“1″).compareTo(new BigDecimal(“1.00″)));

FAQ

¿Que es la precisión?

  • Es el número de bits que se necesitan para guardar un valor. Así, la precisión de int y float es de 32 bits, mientras que las de long y double es de 64 bits. En el caso de BigDecimal, no hay límite de precisión, y esta viene dada por el número de dígitos (parte entera+parte decimal) que tiene el valor guardado en base 10. Por ejemplo, el número 22.3 tiene precisión 3.

¿Que es la exactitud de una operación?

  • Un cálculo es exacto cuando se corresponde con el resultado real de la operación. Double y float no son exactos porque 0.1F+0.2F = 0.30000000447034836, pero BigDecimal si lo es: 0.1 + 0.2 = 0.3.

¿Cómo funciona un BigDecimal por dentro?

  • A grosso modo, no es mas que un array de ints (BigInteger), lo que permite tener precisión ilimitada (no confundir con infinita). Para las operaciones con decimales, simplemente guarda la posición de la coma y opera como si fueran operaciones enteras, y después desplaza la posición de la coma, si corresponde.

¿Por qué un float y un double no son exactos?

  • En realidad si son exactos, el problema es que los convertimos de/a base 10 pero internamente se almacenan en binario. Y todos los números binarios con decimales tienen correspondencia en base 10, pero no todos los números con decimales en base 10 tienen correspondencia en binario. Veamos por qué: todos conocemos como se almacena la parte entera de un número en base 10 en binario. Por ejemplo, el 13 en binario es 1101, lo que significa 1*2^3 + 1*2^2 + 0*2^1+ 1*2^0 = 8+4+0+1 = 13. En la parte decimal de un número en base 10, cada bit vale 1/2, 1/4, 1/8 (es decir, 2^-1, 2^-2, 2^-3), por lo que 0.11 en binario es 1/2+1/4 = 0.75. El problema viene cuando intentas guardar un número como 0.5 en binario, ya que no hay número exacto para ello: en su lugar, se guarda la aproximación más cercana, que es el número periódico 0.0.00110011… Y un número periódico en un ordenador tiene un límite (el de la precisión de su tipo), por lo que es redondeado al final, alternado el valor. Por eso al sumar 0.1 + 0.2 tiene ese pequeño desvío. Más información aquí: http://en.wikipedia.org/wiki/Binary_numeral_system#Fractions_in_binary

¿Qué significa que un numero esté en coma flotante?

  • A muy muy grosso modo (expertos, tened piedad, esto no es un artículo académico) consiste en almacenar un número con decimal (que se denomina mantisa) y un exponente al que elevamos ese número decimal. Esto nos permite guardar números muy grandes y muy pequeños. Por ejemplo, un número como 0.0000000000667 se guarda como 6.667^-11 (mantisa:6.667 y exponente:-11) y se representa como 6.667e-11 (conocido como notación científica). Más info: http://en.wikipedia.org/wiki/IEEE_754-2008

La clase RoundingMode tiene muchos tipos de redondeo, ¿cuál debería usar?

  • Para importes (y casi cualquier contexto normal) se usa HALF_UP y HALF_EVEN. El redondeo HALF_UP, también denominado como “standard rounding”, es el de “toda la vida” y consiste en redondear hacia el número más cercano. Así que si el decimal a truncar acaba de 0 a 4 se redondea hacia abajo, y si acaba de 5 a 9 se redondea hacia arriba. 2.134 -> 2.13, y  2.135 -> 2.14. Sin embargo, parece ser que estadísticamente, este redondeo no es óptimo. HALF_EVEN, también denominado “bankers’ rounding” o “gaussian rounding”, es el redondeo que aplican los bancos y consiste en redondear hacia el número par más cercano. Funciona igual que HALF_UP solo que para el caso de que el decimal acabe en 5, se redondea hacía el número par más próximo independientemente de si está arriba o abajo. Es decir 2.135 -> 2.14, y 2.125 -> 2.12. Los dos acaban en 5, pero uno se redondea hacia arriba y el otro hacia abajo. En ambos casos, el último decimal es par. Más info: http://www.xbeat.net/vbspeed/i_BankersRounding.htm

Bola extra

Si quieres ampliar información, estos son algunos artículos muy interesantes:

Espero que os haya sido de utilidad, enjoy!

 

Deja un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *