MoneyPHP. Работа с деньгами в PHP (Часть 1)
Что такое деньги?
Прежде чем начать работать с деньгами нужно крепко себе уяснить, что деньги не равно числам. То есть да, мы как люди привыкли оперировать числами, к тому же в рамках одного региона мы чаще всего ограничены какой-то одной валютой и поэтому о ней даже не задумываемся. Но когда мы работаем с деньгами в коде, особенно если у нас может быть несколько валют в системе нужно понимать, что деньги это всегда число + валюта.
Как хранить деньги?
Float
“Деньги это же дроби, значит будем хранить как float”.
Когда мы впервые сталкиваемся с деньгами, то обычно начинаем использовать float. И это интуитивно понятно, у денег (по крайней мере у рублей/долларов/евро) есть целая часть и дробная. Давайте рассмотрим такой пример: у нас есть интернет магазин, и к нам приходит клиент с багой:
Я могу установить цену 4.20, но когда ставлю 4.10 - она меняется на 4.09. Почему?
Звучит довольно странно, и мы лезем в код смотреть, что там. И видим там следующее:
1
$price = (int)($amount * 100);
Идея следующая: клиент вводит цену “как есть” в виде float-а ($amount = "4.20"
). Дальше мы умножаем это число, чтобы получить центы (или копейки), приводим к целому числу и например, сохраняем в базу. Всё выглядит весьма валидно. Что здесь может пойти не так? Давайте дебажить:
1
2
var_dump((int)("4.20" * 100)); // int(420)
var_dump((int)("4.10" * 100)); // int(409)
Почему такие странные результаты? Мы ведь здесь вроде даже float нигде не используем. Чтобы понять, почему так происходит нужно немного теории. Всё дело в том, как PHP хранит в памяти числа. У нас нет встроенного Decimal типа данных, поэтому используются либо целые числа, либо с плавающей точкой. Используется формат IEEE-754. Важно знать про этот стандарт то, что числа хранятся в бинарном виде: как мантисса, так и экспонента.
На скриншоте выше я подставил в конвертер число из нашего примера и видно, что в памяти оно уже будет храниться как 4.09999999
. Мы просто присвоили значение переменной, не делали никаких вычислений, а оно уже потеряло точность. И что же происходит в нашем коде дальше?
1
2
3
$priceInUsd = 4.10; // 4.099999904632568359375
$priceInMinorUnits = $priceInUsd * 100; // 409.99999
var_dump((int)$priceInMinorUnits); // int(409)
Число, которое ввел пользователь (“4.10”) уже хранится в памяти как 4.099999904632568359375
. Дальше мы его умножаем на 100
, получаем примерно 409.99999
. А приводя число к int
мы просто отрезаем его дробную часть. Именно отрезаем, а не округляем. И в итоге получается 4.09
.
С float-ом на самом деле проблема не только с округлением, точность можно потерять просто на “ровном месте”:
1
2
3
4
5
$number = 49778510530730964;
var_dump($number); // int(49778510530730964)
$floatNumber = (float)$number;
var_dump($floatNumber); // double(49778510530730960)
Здесь мы просто integer привели в float и у уже последнюю цифру потеряли. Для крипты, где очень большие числа, это может быть очень существенно.
String + BCMath
“Храним как есть: 10 рублей точка 20 копеек”.
Можно попробовать работать с деньгами как со строками, а для арифметики использовать расширение bc_math
. Но с ним тоже не всё так просто. Например, складываем два числа с помощью bcadd()
и если забудем указать третьим параметром точность, то может быть больно:
1
2
3
4
5
$a = "0.01";
$b = "5";
var_dump(bcadd($a, $b)); // "5"
var_dump(bcadd($a, $b, 2)); // "5.01"
Если к примеру это будет биткоин, то можно много потерять. Плюс непонятно как делать округления, например при расчете налогов:
1
2
3
4
5
$a = "123.45";
$b = "1.19";
// 123.45 * 1.19 = 146.9055
var_dump(bcmul($a, $b, 2)); // string(6) "146.90"
123.45 * 1.19
будет равно 146.9055
, но для денег это невалидное число. Его нужно округлить до 146.91
. Как это делать? Нужно где-то прописывать правила и каждый раз это проверять.
Integer
“Давайте всё хранить в минорных единицах (копейки, центы, …).”
По идее такой подход удобен и для передачи данных по апи и для хранения в базе. Однако у integer помимо удобства могут быть и минусы:
- Ограничены. Во время переполнения в большинстве случаев просто молча изменятся. Можно думать что 64-бит integer хватит для представления любой разумной суммы в мире. Но в истории уже были времени гиперинфляций, когда суммы составляли 30 знаков, в то время как 64-битовый integer хранит только 20 знаков. Ну и тут же крипта передает привет.
- Минорные единицы в течении истории могут меняться. Раньше у японской йены были дробные номиналы: 1/100 сен и 1/1000 рин. Сейчас их нет и йена не имеет дробного номинала.
- Постоянно нужно будет делать конвертацию из минорных единиц в читаемый вид. Хранить эту логику на бэкенде или тащить на фронтенд?
Но на самом деле есть еще куча проблем связанных с деньгами, о которых вы скорее всего даже не подозреваете.
Распространенные заблуждения о деньгах
- Все валюты имеют десятые и сотые (копейки, центы). Исключением является японская Йена, у которой только целые значения, не может быть меньше 1 йены. А например, Иранский динар наоборот имеет 3 знака в дробном номинале.
- У любой страны есть своя собственная валюта. Наиболее популярное исключение Евро.
- Цены не могут иметь большую точность, чем меньшая дробная часть валюты. Исключения – цены на бензин в Европе могут быть вида 1.789 euros за литр.
- Никакая страна не использует валюту чужой страны – Эквадор использует USD.
- У страны в обращении может быть только одна валюта: в Панаме есть своя валюта (Бальбоа) и используется доллар.
- И т.д.
Полный список распространенных заблуждений о валютах здесь – https://gist.github.com/rgs/6509585.
Уже виден объем проблем, с которыми можно столкнуться при работе с валютами. И это мы еще не окунулись в мир крипты, где еще могут быть свои исключения и нюансы. В итоге становится понятно, что всё это хэндлить самим просто нереально и лучше заюзать готовую библиотеку.
MoneyPHP
В экосистеме PHP из готовых решений для работы с деньгами и валютами есть только две достойных библиотеки: MoneyPHP и Brick/Money.
MoneyPHP | Brick\Money |
---|---|
Зрелая библиотека (c 2011 г.) | До сих пор нет версии 1.0 |
Не очень user-friendly-интерфейс | user-friendly-интерфейс |
Парсеры, форматеры, конвертеры | Парсеры, форматеры, конвертеры |
Поддержка криптовалют из коробки | Нужно добавлять всё самому |
Установка:
1
composer require moneyphp/money
Для работы с деньгами что в Brick, что в MoneyPHP используется одна и та же концепция: деньги – это value-object, у которого внутри есть валюта и номинал. И одно без другого не имеет смысла. Объект сам обрабатывает все вычисления и конвертации на основе заранее определенного набора правил.
1
2
3
4
5
6
7
8
9
final class Money implements JsonSerializable
{
public function __construct(
private readonly int|string $amount,
private readonly Currency $currency
) {
/* ... */
}
}
Количество денег внутри хранится в виде integer как наименьший номинал выбранной валюты. То есть для рубля – копейки, для битка – сатоши, для йены – йена.
Создание объекта довольное простое: передаем число в наименьшем номинале выбранной валюты.
1
2
3
4
5
use Money\Currency;
use Money\Money;
$five = new Money(500, new Currency('RUB')); // 5,00 ₽
$five = Money::RUB(500);
Объект Money
Объект Money является иммутабельным, то есть изменение значения ведет к созданию нового объекта. Такой подход позволяет избежать множества багов, когда объект с деньгами передается вглубь методов со сложной бизнес логикой. Имея иммутабельный объект, не получится случайно перезаписать его значение.
Простой пример с калькуляцией налога:
1
2
$net = new Money(123, new Currency('USD')); // $1.23
$gross = $net->multiply('1.10', Money::ROUND_UP);
У нас есть сумма 1.23$, умножаем ее на 1.10. Так как объект Money
иммутабельный, то изначальная переменная $net
не меняет своего значения, вместо этого мы получаем новый объект $gross
. При умножении 1.23
на 1.10
мы бы получили 1.353
, что не является валидным значением для доллара. Поэтому явно указываем в какую сторону нужно округлять.
Откуда вообще MoneyPHP знает про валюты, их номиналы и прочее?
Прямо в репозитории есть файлик currency.php, в котором в виде списка представлены валюты: название, номинал, кол-во знаков после запятой:
1 2 3 4 5 6 7 'USD' => array ( 'alphabeticCode' => 'USD', 'currency' => 'US Dollar', 'minorUnit' => 2, 'numericCode' => 840, ),И дальше под капотом библиотека просто загружает этот файлик:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 namespace Money\Currencies; final class ISOCurrencies implements Currencies { // ... private function loadCurrencies(): array { $file = __DIR__ . '/../../resources/currency.php'; if (is_file($file)) { return require $file; } throw new RuntimeException( 'Failed to load currency ISO codes.' ); } }
Базовые операции
Объекты денег можно складывать, вычитать, умножать, делить, получать часть от числа и остаток от деления:
1
2
3
4
5
6
7
8
9
10
11
$value1 = Money::RUB(100000); // 1000₽
$value2 = Money::RUB(50000); // 500₽
$value1->subtract($value2); // 500₽
$value1->add($value2); // 1500₽
$value1->divide(2); // 500₽
$value2->multiply(2); // 1000₽
$value1->ratioOf($value2); // 2.0
$value1->mod(Money::RUB(70000)); // 300₽
Объекты с деньгами можно сравнивать:
1
2
3
4
5
6
7
8
$value1 = Money::RUB(100000); // 1000₽
$value2 = Money::RUB(80000); // 800₽
$result = $value1->isSameCurrency($value2); // true
$result = $value1->equals($value2); // false
$result = $value1->greaterThan($value2); // true
Единственное, что нужно помнить, что сравнивать можно только объекты одной валюты. В противном случае будет исключение:
1
2
3
4
5
6
$valueInRub = new Money(100, new Currency('RUB'));
$valueInUSD = new Money(100, new Currency('USD'));
$valueInRub->equals($valueInUSD);
// InvalidArgumentException: Currencies must be identical