Пост

MoneyPHP. Работа с деньгами в PHP (Часть 1)

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. Все валюты имеют десятые и сотые (копейки, центы). Исключением является японская Йена, у которой только целые значения, не может быть меньше 1 йены. А например, Иранский динар наоборот имеет 3 знака в дробном номинале.
  2. У любой страны есть своя собственная валюта. Наиболее популярное исключение Евро.
  3. Цены не могут иметь большую точность, чем меньшая дробная часть валюты. Исключения – цены на бензин в Европе могут быть вида 1.789 euros за литр.
  4. Никакая страна не использует валюту чужой страны – Эквадор использует USD.
  5. У страны в обращении может быть только одна валюта: в Панаме есть своя валюта (Бальбоа) и используется доллар.
  6. И т.д.

Полный список распространенных заблуждений о валютах здесь – https://gist.github.com/rgs/6509585.

Уже виден объем проблем, с которыми можно столкнуться при работе с валютами. И это мы еще не окунулись в мир крипты, где еще могут быть свои исключения и нюансы. В итоге становится понятно, что всё это хэндлить самим просто нереально и лучше заюзать готовую библиотеку.

MoneyPHP

В экосистеме PHP из готовых решений для работы с деньгами и валютами есть только две достойных библиотеки: MoneyPHP и Brick/Money.

MoneyPHPBrick\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