Может войдёшь?
Черновики Написать статью Профиль

Работа с деньгами и валютами в PHP

перевод

На прошлой неделе я говорил о возможных подводных камнях при работе с деньгами и валютами в ваших приложениях. Есть много вещей, которые стоит учитывать при работе с деньгами в приложении, и множество хороших практик, которые должны непременно использоваться в вашем коде.

В этой статье я покажу вам, что надо делать для абстрагирования многих из этих хороших практик в PHP-пакете для работы с деньгами. Абстрагируя этот код в его собственный пакет, мы можем подключить его к любому PHP-приложению, которому требуется работать с деньгами. Это значит, что мы не должны думать обо всех деталях реализации в каждом приложении.

Будем считать, что вы уже создали структуру пакета, которая соответствует стандарту PSR-4, описанному мной в этой статье.

Работа с валютами

Как я говорил на прошлой неделе, управление различными валютами — чрезвычайно важный аспект при создании приложения, поддерживающего международную торговлю.

В мире существует много различных типов валют, и для каждого типа нам требуется множество данных. К счастью, щедрый мир Open Source уже исследовал и предоставил список мета данных, которые мы можем использовать не изобретая велосипед. Это и есть прелесть Open Source.

Создайте новую папку config в вашей папке src. Затем скопируйте два json-файла из репозитория RubyMoney на GitHub. Пакет, который мы создадим, будет бессовестным портом RubyMoney на PHP.

Эти два json-файла служат постоянным хранилищем всех различных типов мировых валют.

Далее нам надо создать объект для работы с этими типами валют.

Создайте новый файл Currency.php (Валюта) в папке src и задайте свойства класса для каждого ключа json-конфигурации:

PHP
<?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().

PHP
/**
 * Создание нового экземпляра 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
<?php namespace PhilipBrown\Money\Exception;

use 
Exception;

class 
InvalidCurrencyException extends Exception {}

Объект PHPCurrency — объект-значение, который сделает работу с различными валютами более простой. Чтобы закончить создание класса, нам понадобятся некоторые getter-методы, которые обеспечат цельный API для работы с каждым объектом PHPCurrency. Я также добавил статический метод PHPinit для некоторого синтаксического сахара, а также метод PHP__toString() для вывода объекта в виде строки.

Вот класс целиком:

PHP
<?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
<?php namespace PhilipBrown\Money;

class 
Money {

}

Деньги — это объект-значение в мире программирования, и чтобы создать новый объект PHPMoney мы должны предоставить величину и тип PHPCurrency:

PHP
/**
 * Дробная часть величины
 *
 * @var int
 */
protected $fractional;

/**
 * Валюта величины
 *
 * @var PhilipBrown\Money\Currency
 */
protected $currency;

/**
 * Создание нового экземпляра Money
 *
 * @param int $fractional
 * @param PhilipBrown\Money\Currency $currency
 * @return void
 */
public function __construct($fractionalCurrency $currency)
{
  
$this->fractional $fractional;
  
$this->currency $currency;
}

Снова, как и с объектом PHPCurrency, я добавлю немного синтаксического сахара с помощью статического метода PHPinit():

PHP
/**
 * Статическая функция для создания нового экземпляра 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-метода для доступа к защищённым свойствам класса:

PHP
/**
 * Получение дробного значения объекта
 *
 * @return int
 */
public function getCentsParameter()
{
  return 
$this->fractional;
}

/**
 * Получение объекта Currency
 *
 * @return PhilipBrown\Money\Currency
 */
public function getCurrencyParameter()
{
  return 
$this->currency;
}

Ещё я добавлю волшебный метод PHP__get(), чтобы свойства класса PHPcents и PHPcurrency автоматически вызывали два следующих метода:

PHP
/**
 * Волшебный метод для динамического получения параметров объекта
 *
 * @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 имеют одинаковую валюту, мы можем добавить метод для проверки:

PHP
/**
 * Проверка кода Iso для оценки равенства валют
 *
 * @param PhilipBrown\Money\Money
 * @return bool
 */
public function isSameCurrency(Money $money)
{
  return 
$this->currency->getIsoCode() == $money->currency->getIsoCode();
}

Как я писал в статье В чём различия между Сущностями и Объектами-значениями, объекты-значения основывают равенство на атрибутах объекта, а не их идентичности.

Мы можем добавить метод для проверки равенства указанного экземпляра PHPMoney и текущего экземпляра PHPMoney:

PHP
/**
 * Проверка равенства двух объектов Money.
 * Сначала проверяется валюта, затем величина.
 *
 * @param PhilipBrown\Money\Money
 * @return bool
 */
public function equals(Money $money)
{
  return 
$this->isSameCurrency($money) && $this->cents == $money->cents;
}

Важно проверить совпадение и валют и величин, чтобы мы могли утверждать, что два объекта PHPMoney равны.

Выполнение вычислений над объектами Money

В коммерческом приложении вам неизбежно потребуется выполнять вычисления над объектами PHPMoney. Возможно самым распространённым случаем будет сложение стоимости двух продуктов:

PHP
/**
 * Сложение величин двух объектов 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() почти идентичен предыдущему, только он вычитает величины вместо их сложения:

PHP
/**
 * Вычитание величины одного объекта 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 я буду использовать этот метод:

PHP
/**
 * Умножение объекта Money и возвращение нового объекта
 *
 * @param int $number
 * @return PhilipBrown\Money\Money
 */
public function multiply($number)
{
  return 
Money::init((int) round($this->cents $number0PHP_ROUND_HALF_EVEN), $this->currency->getIsoCode());
}

А для деления объекта PHPMoney я буду использовать этот метод:

PHP
/**
 * Деление объекта Money и возвращение нового объекта
 *
 * @param int $number
 * @return PhilipBrown\Money\Money
 */
public function divide($number)
{
  return 
Money::init((int) round($this->cents $number0PHP_ROUND_HALF_EVEN), $this->currency->getIsoCode());
}

Заметьте, в обоих методах я передаю в функцию PHPround() значение точности PHP0 и режим PHPPHP_ROUND_HALF_EVEN. Вы должны убедиться в том, что везде в вашем приложении используется выбранный способ округления.

Работа с этим пакетом

Для создания нового объекта PHPMoney вы либо создаёте экземпляр как обычно, либо используете удобный статический метод PHPinit().

PHP
// Создание нового объекта Money, представляющего $5 USD
$m Money::init(500'USD');
$m = new Money(500'USD');

Для доступа к величине объекта PHPMoney вы можете просто запросить свойство PHPcents. Для получения валюты объекта вы можете запросить свойство PHPcurrency:

PHP
$m->cents// 500
$m->currency// United States Dollar

Равенство важно для работы со множеством различных типов валют. У вас не должно быть возможности слепо складывать две разные валюты без какого-либо процесса обмена:

PHP
$m Money::init(500'USD');
$m->isSameCurrency(Money::init(500'GBP')); // false

Объект-значение — объект, представляющий сущность, равенство которой не основано на идентичности: то есть два объекта-значения равны, когда они имеют одинаковые значения, это не обязательно должны быть одинаковые объекты:

PHP
$one Money::init(500'USD');
$two Money::init(500'USD');
$three Money::init(501'USD');

$one->equals($two); // true
$one->equals($three); // false

Вам неизбежно понадобится складывать, вычитать, умножать и делить денежные величины в вашем приложении:

PHP
$one Money::init(500'USD');
$two Money::init(500'USD');

$three $one->add($two);
$three->cents // 1000

Повторюсь, у вас не должно быть возможности складывать величины с разными валютами без какого-либо процесса обмена:

PHP
$one Money::init(500'USD');
$two Money::init(500'GBP');

$three $one->add($two); // Money\Exception\InvalidCurrencyException

Заключение

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

Использование этого пакета поможет избежать множества проблем и подводных камней, с которыми вы можете столкнуться при работе с деньгами и валютами в вашем приложении. Однако, как я писал в статье Как обрабатывать деньги и валюты в веб-приложениях, есть ещё ряд вещей, о которых мы должны думать при работе с деньгами в приложениях.

Если вы захотите использовать этот пакет в одном из ваших проектов, он доступен на GitHub. Загляните на RubyMoney и Mathias Verraes для вдохновения.

На следующей неделе я покажу вам, как создать пакет, который может использовать этот пакет Money для абстрагирования еще большего количества головной боли при работе с деньгами в вашем приложении. Наслаивая эти небольшие концентрированные пакеты, мы можем создать прочный фундамент для построения высококачественных сайтов и приложений в области электронной коммерции.

Как вы считаете, полезен ли этот материал? Да Нет

Написать комментарий

Разметка: ? ?

Авторизуйся, чтобы прокомментировать.