Dinero, fechas y las decisiones que no se deshacen
Dos tipos de dato deciden más del futuro de una aplicación que el framework. Los dos se eligen en la primera semana, casi siempre sin que nadie se dé cuenta de que estaba decidiendo algo.
Casi todo lo que una aplicación hace mal tiene arreglo. Un controlador malo se refactoriza, una consulta lenta se indexa, una interfaz fea se rediseña.
Dos decisiones no son así, porque son decisiones sobre lo que quedó escrito. Si los importes de su base de datos son aproximaciones, seguirán siendo aproximaciones. Si una marca de tiempo perdió la información necesaria para interpretarla, esa información ya no está.
Las dos se toman en la primera semana, normalmente por quien escribe la primera migración, normalmente sin que nadie note que se estaba decidiendo algo.
El dinero no es un número
Un float no puede contener 0,1 exactamente, porque es binario y 0,1 no lo es. Cada operación aritmética arrastra un pequeño error, y los errores se acumulan.
$total = 0.1 + 0.2; // 0.30000000000000004
$total === 0.3; // falseEsto no es una rareza de PHP; es cómo funciona la coma flotante binaria en todas partes. Aplicado al dinero produce una factura que se desvía un céntimo, un saldo que nunca llega del todo a cero, y un informe de conciliación que nadie puede cerrar.
Guarde unidades menores enteras. El importe en la unidad más pequeña de la divisa, como entero. Nada en el camino puede ser un float, así que nada puede desviarse.
$table->bigInteger('amount_minor'); // 1050 = 10,50
$table->char('currency', 3);
$table->unsignedTinyInteger('currency_exponent')->default(2);Guarde la divisa, siempre. Un importe sin divisa es un número, no dinero. La multidivisa llega después en más productos de los que la planifican, y añadir la columna más tarde significa decidir qué quería decir cada fila existente.
Guarde el exponente en lugar de suponer dos. No todas las divisas tienen dos decimales: unas no tienen ninguno y otras tienen tres. El código que multiplica por cien está mal para esas, en las dos direcciones.
Decida el redondeo una vez, de forma explícita. El impuesto sobre tres líneas redondeado por línea da un total distinto del impuesto sobre la suma. Ninguno está mal; tener los dos en la misma base de código, sí. Escriba cuál hace este sistema.
No deje que se acerque un float. Una columna decimal leída a un float de PHP ha deshecho el trabajo. Conviértala a cadena o a entero y haga la aritmética con enteros.
El tiempo tiene tres formas distintas
El error es tratarlas como un solo tipo.
Un instante: cuándo ocurrió algo. Un único valor correcto, guardado en UTC, convertido para mostrar. Creado, pagado, sesión iniciada. Esto es lo que son casi todas las columnas de marca de tiempo y donde UTC es inequívocamente lo correcto.
Una fecha: un día sin hora dentro. Un cumpleaños, la fecha de una factura, un festivo. Guardarla como marca de tiempo mete una zona horaria en algo que no tiene ninguna, y produce el fallo clásico en el que una fecha se desplaza un día para los usuarios de un lado del mundo.
$table->date('invoice_date'); // no timestampUna hora de pared futura: un evento programado que tiene que ocurrir a una hora local. Esta es la que se rompe, y es por lo que "guarde siempre UTC" es un consejo incompleto.
Un recordatorio puesto para las 09:00 del próximo marzo, convertido hoy a UTC, se guarda como un instante concreto. Si las reglas de esa zona cambian antes de marzo - y los gobiernos las cambian, a veces con semanas de aviso - el instante guardado ya no son las 09:00 locales. Se disparará a la hora equivocada y nada informará de ningún error.
Lo que hay que guardar es la intención: la hora local, el nombre de la zona y la regla. El instante se calcula cuando hace falta.
$table->time('local_time');
$table->string('timezone'); // 'Europe/Istanbul', no '+03:00'La zona es un nombre, no un desplazamiento. Un desplazamiento es lo que una zona resultó ser en cierto momento; el nombre es la regla, y la regla es lo que sobrevive a un cambio.
La que pilla a todo el mundo
Carbon::now() devuelve la zona horaria configurada en la aplicación. Si está
puesta a algo local y una columna está casteada a datetime, el valor que se
escribe es hora local en una columna que todo lo demás supone en UTC.
Funciona perfectamente hasta que cambia la hora, o hasta que un segundo servidor tiene otra configuración, y entonces hay una tabla con dos clases de marca de tiempo y nada que las distinga.
Ponga la zona de la aplicación en UTC y convierta en los bordes: al mostrar y al aceptar entrada. La zona del usuario va en el registro del usuario, no en una global.
Por qué merecen una discusión en la primera semana
Todo lo demás en una aplicación es comportamiento, y el comportamiento se sustituye. Estas dos cosas son lo que quedó registrado.
Migrar una columna float a enteros significa decidir qué debería haber sido un valor inexacto, fila a fila, y cada una de esas filas es el dinero de alguien. Una marca de tiempo que perdió su zona no permite inferirla: la información no está ahí para recuperarla.
Diez minutos de desacuerdo mientras se escribe la primera migración es todo lo que cuesta acertar en las dos.
Si las columnas ya están mal, la reparación es una migración de datos con criterio dentro, no un cambio de esquema. Ese trabajo lo hacemos nosotros, y va por los mismos pasos cuidadosos que un cambio de esquema que si no bloquearía la tabla.
