Короткий пост о важном — как в проекте работать с деньгами, датами и перечислимыми типами.
Это уже давно не секрет для опытных разработчиков. Но и им иногда нужно об этом вспомнить, а новички до этих простых истин ещё могли не дойти в силу опыта.
Даты только в UTC
Даты играют очень важную роль. От них могут зависеть поставки, отчёты по платежам или журналирование каких-то внутренних процессов. Всё работает хорошо, пока в системе не появляются участники или сущности, которые работают (или хотя бы даже теоретически могут работать) в другой временной зоне.
Для этого следует использовать UTC как надёжную опорную точку. Переводить даты в другой часовой пояс есть смысл только при выводе.
Во-первых, при работе с сырыми значениями это избавит от постоянной путаницы «Что это за дата?» или «А не перевёл ли я её уже в какой-то часовой пояс?».
Во-вторых, часовые пояса и переход на летнее (зимнее) время могут регулироваться на уровне государств. Вспомните, когда при Медведеве, на фоне сокращавшихся часовых поясов, то отменяли зимнее время, то возвращали.
Это влечёт обновление базы Олсона, тучи софта и библиотек, что занимает время: часовой пояс сменился уже сейчас, а когда ты сможешь его использовать — непонятно.
UTC — он и есть UTC. Он стабилен и не меняется.
Деньги только целым числом
Любые финансовые операции должны быть точны. Погрешность в копейку при расчётах или в отчётах за год может обернуться проблемами для бизнеса.
Решение простое. В базе данных нужно хранить рубли в копейках. Доллары — в центах. И так далее. Поле БД должно быть unsigned integer (как минимум).
Перевод в рубли (в дробное число) — также, как с датами — только чтобы показать пользователю. Все операции, которые не видит пользователь, должны происходить с сырыми целочисленными значениями.
Я предпочитаю не использовать функции окруления при записи данных. Например, на вход извне поступают рубли дробным числом (из формы, из API, и пр.), а мне нужно перевести это в копейки для сохранения в БД. Тогда умножаю на 100 и приводим к integer. На php это выглядит так:
$kopeks = (int)($rubles * 100)
Это отметает всю дробную часть, оставшуюся после умножения на 100.
Если мне нужно получить копейки из рублей, делаем обратную операцию: делим копейки на 100, но поскольку теперь мне нужна дробная часть, то я округляю полученный float до 2 знаков после запятой:
$rubles = round($kopeks / 100, 2)
Как известно, round() принимает третьим параметром способ округления. Здесь меня вполне устраивает дефолтный флаг PHP_ROUND_HALF_UP, поскольку после деления и так 2 разряда в дробной части (это и передаю 2-ым параметром), а погрешность в могла бы наблюдаться при 3+ знаках после запятой. И то, даже эта погрешность не повлияла бы на данные — всё считается в копейках, а рубли создаются искусственно только для вывода на экран или куда-то ещё.
Если в вашем проекте нужна другая точность, соответственно, делите или умножайте на 10, 1000 и т. д.
Enum на уровне БД используй с умом
Енам даёт возможноть хранить в поле одно из заранее заданных на уровне БД строковых значений.
Учитывай, что если у твоей сущности есть такое поле, то любые доработки в будущем придётся производить и на уровне БД. А это дополнительный уровень ответственности и осторожности на проекте.
Например, у меня есть таблица с логами действий внутри системы. Что-то в духе «Пользователь Х выполнил операцию Y над объектом Z».
Каждая запись лога (таблицы) должна иметь 1 из семи уровней — от DEBUG до EMERGENCY. Это поле я сделаю типа enum. Изменять количество уровней я больше никогда не буду, ведь по PSR-3 их всего восемь.
В этой ситуации енам действительно оправдан, и вот почему.
Во-первых, эти восемь покрывают вообще все потребности и другие уровни придумывать я точно не буду. И даже с учётом того, что по тому же PSR-3 фактически остаётся возможность записывать логи любого другого уровня, п. 1.1 гласит:
Users SHOULD NOT use a custom level without knowing for sure the current implementation supports it.
Во-вторых, на мой взгляд, вероятность, что этот PSR пересмотрят хотя бы в ближайшем десятилетии, крайне мала.
Так что здесь действительно есть смысл использовать enum. А вот плохой пример использования енама.
Где-то в проекте нужно указывать способ оплаты. В БД есть поле payment_type типа enum(‘card’,’account’). То есть пользователь может выбрать оплату либо по карте, либо с внутреннего счёта.
Настал день икс, и в проект вводится новый тип оплаты — через PayPal, например. Теперь придётся добавлять новое значение «paypal» в список допустимых значений поля payment_type.
Ты написал миграцию для этого и успешно отладил локально. Но во время её выполнения на проде что-то пошло не так и типы существующих оплат сбились. Теперь твоя задача не доработать фичу, а исправить серьёзную проблему.
А можно было заранее использовать другой тип данных для этого поля (int | string), придумать для него новое значение (5 | «paypal») и работать с ним только на уровне кода.