На прошлой неделе я говорил о возможных подводных камнях при работе с деньгами и валютами в ваших приложениях. Есть много вещей, которые стоит учитывать при работе с деньгами в приложении, и множество хороших практик, которые должны непременно использоваться в вашем коде.
В этой статье я покажу вам, что надо делать для абстрагирования многих из этих хороших практик в PHP-пакете для работы с деньгами. Абстрагируя этот код в его собственный пакет, мы можем подключить его к любому PHP-приложению, которому требуется работать с деньгами. Это значит, что мы не должны думать обо всех деталях реализации в каждом приложении.
Будем считать, что вы уже создали структуру пакета, которая соответствует стандарту PSR-4, описанному мной в этой статье.
Работа с валютами
Как я говорил на прошлой неделе, управление различными валютами — чрезвычайно важный аспект при создании приложения, поддерживающего международную торговлю.
В мире существует много различных типов валют, и для каждого типа нам требуется множество данных. К счастью, щедрый мир Open Source уже исследовал и предоставил список мета данных, которые мы можем использовать не изобретая велосипед. Это и есть прелесть Open Source.
Создайте новую папку config в вашей папке src. Затем скопируйте два json-файла из репозитория RubyMoney на GitHub. Пакет, который мы создадим, будет бессовестным портом RubyMoney на PHP.
Эти два json-файла служат постоянным хранилищем всех различных типов мировых валют.
Далее нам надо создать объект для работы с этими типами валют.
Создайте новый файл Currency.php (Валюта) в папке src и задайте свойства класса для каждого ключа json-конфигурации:
<?php namespace PhilipBrown\Money;
class Currency {
/**
* @var int
*/
protected $priority;
/**
* @var string
*/
protected $iso_code;
/**
* @var string
*/
protected $name;
/**
* @var string
*/
protected $symbol;
/**
* @var array
*/
protected $alternate_symbols;
/**
* @var string
*/
protected $subunit;
/**
* @var int
*/
protected $subunit_to_unit;
/**
* @var bool
*/
protected $symbol_first;
/**
* @var string
*/
protected $html_entity;
/**
* @var string
*/
protected $decimal_mark;
/**
* @var string
*/
protected $thousands_separator;
/**
* @var int
*/
protected $iso_numeric;
}
Чтобы создать новый объект PHPCurrency
, мы будем запрашивать название валюты. Добавьте в класс следующий метод PHP__construct()
.
/**
* Создание нового экземпляра Currency
*
* @param string $name
* @return void
*/
public function __construct($name)
{
$name = strtolower($name);
$currencies = array_merge(
json_decode(file_get_contents(__DIR__.'/config/currency_iso.json'), true),
json_decode(file_get_contents(__DIR__.'/config/currency_non_iso.json'), true)
);
if(!array_key_exists($name, $currencies)) {
throw new InvalidCurrencyException("$name is not a valid currency");
}
$this->priority = $currencies[$name]['priority'];
$this->iso_code = $currencies[$name]['iso_code'];
$this->name = $currencies[$name]['name'];
$this->symbol = $currencies[$name]['symbol'];
$this->alternate_symbols = $currencies[$name]['alternate_symbols'];
$this->subunit = $currencies[$name]['subunit'];
$this->subunit_to_unit = $currencies[$name]['subunit_to_unit'];
$this->symbol_first = $currencies[$name]['symbol_first'];
$this->html_entity = $currencies[$name]['html_entity'];
$this->decimal_mark = $currencies[$name]['decimal_mark'];
$this->thousands_separator = $currencies[$name]['thousands_separator'];
$this->iso_numeric = $currencies[$name]['iso_numeric'];
}
В этом методе мы принимаем название валюты в качестве аргумента и затем конвертируем его в строку в нижнем регистре.
Далее мы берём содержимое двух json-файлов и объединяем их в массив, с которым будем работать. Если переданное название валюты не совпадает с одним из находящихся в мета данных в json-файлах, мы можем просто выдавать исключение.
Если же валюта найдена в мета данных, мы можем заполнить все свойства класса.
Как и в прошлых статьях про пакеты, создайте новую папку Exception и скопируйте следующий изменённый класс исключения:
<?php namespace PhilipBrown\Money\Exception;
use Exception;
class InvalidCurrencyException extends Exception {}
Объект PHPCurrency
— объект-значение, который сделает работу с различными валютами более простой. Чтобы закончить создание класса, нам понадобятся некоторые getter-методы, которые обеспечат цельный API для работы с каждым объектом PHPCurrency
. Я также добавил статический метод PHPinit
для некоторого синтаксического сахара, а также метод PHP__toString()
для вывода объекта в виде строки.
<?php namespace PhilipBrown\Money;
use PhilipBrown\Money\Exception\InvalidCurrencyException;
class Currency {
/**
* @var int
*/
protected $priority;
/**
* @var string
*/
protected $iso_code;
/**
* @var string
*/
protected $name;
/**
* @var string
*/
protected $symbol;
/**
* @var array
*/
protected $alternate_symbols;
/**
* @var string
*/
protected $subunit;
/**
* @var int
*/
protected $subunit_to_unit;
/**
* @var bool
*/
protected $symbol_first;
/**
* @var string
*/
protected $html_entity;
/**
* @var string
*/
protected $decimal_mark;
/**
* @var string
*/
protected $thousands_separator;
/**
* @var int
*/
protected $iso_numeric;
/**
* Создание нового экземпляра Currency
*
* @param string $name
* @return void
*/
public function __construct($name)
{
$name = strtolower($name);
$currencies = array_merge(
json_decode(file_get_contents(__DIR__.'/config/currency_iso.json'), true),
json_decode(file_get_contents(__DIR__.'/config/currency_non_iso.json'), true)
);
if(!array_key_exists($name, $currencies)) {
throw new InvalidCurrencyException("$name is not a valid currency");
}
$this->priority = $currencies[$name]['priority'];
$this->iso_code = $currencies[$name]['iso_code'];
$this->name = $currencies[$name]['name'];
$this->symbol = $currencies[$name]['symbol'];
$this->alternate_symbols = $currencies[$name]['alternate_symbols'];
$this->subunit = $currencies[$name]['subunit'];
$this->subunit_to_unit = $currencies[$name]['subunit_to_unit'];
$this->symbol_first = $currencies[$name]['symbol_first'];
$this->html_entity = $currencies[$name]['html_entity'];
$this->decimal_mark = $currencies[$name]['decimal_mark'];
$this->thousands_separator = $currencies[$name]['thousands_separator'];
$this->iso_numeric = $currencies[$name]['iso_numeric'];
}
/**
* Создание нового объекта Currency
*
* @param string $name
* @return PhilipBrown\Money\Currency
*/
public static function init($name)
{
return new Currency($name);
}
/**
* Get Priority
*
* @return string
*/
public function getPriority()
{
return $this->priority;
}
/**
* Get ISO Code
*
* @return string
*/
public function getIsoCode()
{
return $this->iso_code;
}
/**
* Get Name
*
* @return string
*/
public function getName()
{
return $this->name;
}
/**
* Get Symbol
*
* @return string
*/
public function getSymbol()
{
return $this->symbol;
}
/**
* Get Alternate Symbols
*
* @return string
*/
public function getAlternateSymbols()
{
return $this->alternate_symbols;
}
/**
* Get Subunit
*
* @return string
*/
public function getSubunit()
{
return $this->subunit;
}
/**
* Get Subunit to unit
*
* @return string
*/
public function getSubunitToUnit()
{
return $this->subunit_to_unit;
}
/**
* Get Symbol First
*
* @return string
*/
public function getSymbolFirst()
{
return $this->symbol_first;
}
/**
* Get HTML Entity
*
* @return string
*/
public function getHtmlEntity()
{
return $this->html_entity;
}
/**
* Get Decimal Mark
*
* @return string
*/
public function getDecimalMark()
{
return $this->decimal_mark;
}
/**
* Get Thousands Seperator
*
* @return string
*/
public function getThousandsSeperator()
{
return $this->thousands_separator;
}
/**
* Get ISO Numberic
*
* @return string
*/
public function getIsoNumeric()
{
return $this->iso_numeric;
}
/**
* @return string
*/
public function __toString()
{
return $this->getName();
}
}
Работа с деньгами
Теперь, когда мы можем представлять валюты как объекты в пакете, нам также нужна возможность представлять денежные величины как объекты.
Создайте новый класс Money.php в папке src:
<?php namespace PhilipBrown\Money;
class Money {
}
Деньги — это объект-значение в мире программирования, и чтобы создать новый объект PHPMoney
мы должны предоставить величину и тип PHPCurrency
:
/**
* Дробная часть величины
*
* @var int
*/
protected $fractional;
/**
* Валюта величины
*
* @var PhilipBrown\Money\Currency
*/
protected $currency;
/**
* Создание нового экземпляра Money
*
* @param int $fractional
* @param PhilipBrown\Money\Currency $currency
* @return void
*/
public function __construct($fractional, Currency $currency)
{
$this->fractional = $fractional;
$this->currency = $currency;
}
Снова, как и с объектом PHPCurrency
, я добавлю немного синтаксического сахара с помощью статического метода PHPinit()
:
/**
* Статическая функция для создания нового экземпляра Money.
*
* @param int $value
* @param string $currency
* @return PhilipBrown\Money\Money
*/
public static function init($value, $currency)
{
return new Money($value, new Currency($currency));
}
И также как в объекте PHPCurrency
я добавлю два getter-метода для доступа к защищённым свойствам класса:
/**
* Получение дробного значения объекта
*
* @return int
*/
public function getCentsParameter()
{
return $this->fractional;
}
/**
* Получение объекта Currency
*
* @return PhilipBrown\Money\Currency
*/
public function getCurrencyParameter()
{
return $this->currency;
}
Ещё я добавлю волшебный метод PHP__get()
, чтобы свойства класса PHPcents
и PHPcurrency
автоматически вызывали два следующих метода:
/**
* Волшебный метод для динамического получения параметров объекта
*
* @return mixed
*/
public function __get($param)
{
$method = 'get'.ucfirst($param).'Parameter';
if(method_exists($this, $method))
return $this->{$method}();
}
Как я написал в статье Что такое волшебные методы PHP, этот волшебный метод будет вызываться, когда вы попытаетесь получить доступ к свойствам класса, которые не открыты (public). В этом случае метод PHP__get()
будет принимать в качестве аргумента требуемое свойство и проверять, определён ли соответствующий getter-метод.
Важность равенства
При работе с деньгами в приложении чрезвычайно важно равенство. Например, будет очень плохо, если вы сможете сложить две величины с разными валютами, потому что это приведёт к бухгалтерскому кошмару.
Чтобы убедиться в том, что два объекта PHPMoney
имеют одинаковую валюту, мы можем добавить метод для проверки:
/**
* Проверка кода Iso для оценки равенства валют
*
* @param PhilipBrown\Money\Money
* @return bool
*/
public function isSameCurrency(Money $money)
{
return $this->currency->getIsoCode() == $money->currency->getIsoCode();
}
Как я писал в статье В чём различия между Сущностями и Объектами-значениями, объекты-значения основывают равенство на атрибутах объекта, а не их идентичности.
Мы можем добавить метод для проверки равенства указанного экземпляра PHPMoney
и текущего экземпляра PHPMoney
:
/**
* Проверка равенства двух объектов Money.
* Сначала проверяется валюта, затем величина.
*
* @param PhilipBrown\Money\Money
* @return bool
*/
public function equals(Money $money)
{
return $this->isSameCurrency($money) && $this->cents == $money->cents;
}
Важно проверить совпадение и валют и величин, чтобы мы могли утверждать, что два объекта PHPMoney
равны.
Выполнение вычислений над объектами Money
В коммерческом приложении вам неизбежно потребуется выполнять вычисления над объектами PHPMoney
. Возможно самым распространённым случаем будет сложение стоимости двух продуктов:
/**
* Сложение величин двух объектов Money и возвращение нового объекта Money.
*
* @param PhilipBrown\Money\Money $money
* @return PhilipBrown\Money\Money
*/
public function add(Money $money)
{
if($this->isSameCurrency($money))
{
return Money::init($this->cents + $money->cents, $this->currency->getIsoCode());
}
throw new InvalidCurrencyException("You can't add two Money objects with different currencies");
}
Как я уже упоминал, перед сложением нам надо проверить, что валюты обоих объектов PHPMoney
совпадают.
Если валюты объектов совпадают, мы можем взять величину текущего объекта и величину объекта, который мы хотим добавить, а также валюту для создания нового объекта PHPMoney
. Помните, объекты-значения неизменны и поэтому, если вы хотите изменить величину объекта-значения, вам надо уничтожить текущий объект и создать новый.
Наконец, если валюты объектов не совпадают, мы можем передать исключение, так как это действительно плохая ситуация.
Метод PHPsubtract()
почти идентичен предыдущему, только он вычитает величины вместо их сложения:
/**
* Вычитание величины одного объекта Money из другого и возвращение нового объекта
*
* @param PhilipBrown\Money\Money $money
* @return PhilipBrown\Money\Money
*/
public function subtract(Money $money)
{
if($this->isSameCurrency($money))
{
return Money::init($this->cents - $money->cents, $this->currency->getIsoCode());
}
throw new InvalidCurrencyException("You can't subtract two Money objects with different currencies");
}
Умножение и деление объектов PHPMoney
немного сложнее, так как мы можем столкнуться с ситуациями, когда нам надо округлять величину до целого числа.
Функция PHP round() может нам в этом помочь.
Для умножения объекта PHPMoney
я буду использовать этот метод:
/**
* Умножение объекта Money и возвращение нового объекта
*
* @param int $number
* @return PhilipBrown\Money\Money
*/
public function multiply($number)
{
return Money::init((int) round($this->cents * $number, 0, PHP_ROUND_HALF_EVEN), $this->currency->getIsoCode());
}
А для деления объекта PHPMoney
я буду использовать этот метод:
/**
* Деление объекта Money и возвращение нового объекта
*
* @param int $number
* @return PhilipBrown\Money\Money
*/
public function divide($number)
{
return Money::init((int) round($this->cents / $number, 0, PHP_ROUND_HALF_EVEN), $this->currency->getIsoCode());
}
Заметьте, в обоих методах я передаю в функцию PHPround()
значение точности PHP0
и режим PHPPHP_ROUND_HALF_EVEN
. Вы должны убедиться в том, что везде в вашем приложении используется выбранный способ округления.
Работа с этим пакетом
Для создания нового объекта PHPMoney
вы либо создаёте экземпляр как обычно, либо используете удобный статический метод PHPinit()
.
// Создание нового объекта Money, представляющего $5 USD
$m = Money::init(500, 'USD');
$m = new Money(500, 'USD');
Для доступа к величине объекта PHPMoney
вы можете просто запросить свойство PHPcents
. Для получения валюты объекта вы можете запросить свойство PHPcurrency
:
$m->cents; // 500
$m->currency; // United States Dollar
Равенство важно для работы со множеством различных типов валют. У вас не должно быть возможности слепо складывать две разные валюты без какого-либо процесса обмена:
$m = Money::init(500, 'USD');
$m->isSameCurrency(Money::init(500, 'GBP')); // false
Объект-значение — объект, представляющий сущность, равенство которой не основано на идентичности: то есть два объекта-значения равны, когда они имеют одинаковые значения, это не обязательно должны быть одинаковые объекты:
$one = Money::init(500, 'USD');
$two = Money::init(500, 'USD');
$three = Money::init(501, 'USD');
$one->equals($two); // true
$one->equals($three); // false
Вам неизбежно понадобится складывать, вычитать, умножать и делить денежные величины в вашем приложении:
$one = Money::init(500, 'USD');
$two = Money::init(500, 'USD');
$three = $one->add($two);
$three->cents // 1000
Повторюсь, у вас не должно быть возможности складывать величины с разными валютами без какого-либо процесса обмена:
$one = Money::init(500, 'USD');
$two = Money::init(500, 'GBP');
$three = $one->add($two); // Money\Exception\InvalidCurrencyException
Заключение
В этой статье мы создали пакет, который абстрагирует множество головной боли при работе с деньгами. Мы создали объекты, которые представляют деньги и валюты, и мы реализовали некоторые правила того, как различные величины могут рассчитываться, храниться и сравниваться в вашем приложении.
Использование этого пакета поможет избежать множества проблем и подводных камней, с которыми вы можете столкнуться при работе с деньгами и валютами в вашем приложении. Однако, как я писал в статье Как обрабатывать деньги и валюты в веб-приложениях, есть ещё ряд вещей, о которых мы должны думать при работе с деньгами в приложениях.
Если вы захотите использовать этот пакет в одном из ваших проектов, он доступен на GitHub. Загляните на RubyMoney и Mathias Verraes для вдохновения.
На следующей неделе я покажу вам, как создать пакет, который может использовать этот пакет Money для абстрагирования еще большего количества головной боли при работе с деньгами в вашем приложении. Наслаивая эти небольшие концентрированные пакеты, мы можем создать прочный фундамент для построения высококачественных сайтов и приложений в области электронной коммерции.