Как я писал в отдельной статье, не стоит хранить деньги в float, лучше использовать объектные обёртки.

Моё любимое решение, это использование объектов-значений (шаблон проектирования “Value Object”) для денег.

Что за Value Object?

Value Object — это объект, главной целью которого будет представление типа данных в системе.

Для простых данных мы можем использовать встроенные типы, например если мы храним в переменной количество заходов на сайт за сутки, то используем int.

Value Object: день недели, класс WeekDay

Но что если нам потребовались какие-то данные со своей собственной логикой?

Например, день недели.

При написании кода который обрабатывает день недели, нам потребуется учитывать:

  • День недели может быть только в диапазоне от 1 до 7
  • После воскресенья идёт понедельник
  • Перед понедельником идёт воскресенье

Мы можем использовать тип int для хранения дня недели. Но в этом случае везде где значение передаётся в метод, извлекается из внешних источников, и выполняются операции нам придётся явно вызывать проверки на корректность значения.

Если же мы забудем где-то вызвать проверку, некорректные данные “расползутся” по системе и придётся долго с помощью отладки выяснять, где именно мы пропустили или создали некорректные данные.

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

class WeekDay 
{
	private int $dayOfWeek;

	public function __construct(int $dayOfWeek)
	{
		if (!$this->isValid($dayOfWeek)) {
			throw new \LogicException('Некорректный день недели: ' . $dayOfWeek);			
		}
		
		$this->dayOfWeek = $dayOfWeek;
	}

	public function nextDay(): self
	{
		...
	}

	public function previousDay(): self
	{
		...
	}

	public function number(): int
	{
		return $this->dayOfWeek;
	}
	
	...
}

Теперь мы просто не сможем передать некорректное значение, так как при любой попытке создать некорректный объект просто вылетит ошибка.

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

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

Value Object: деньги, класс Money

Всё то же самое применимо и для денег.

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

Я использую расширение PHP bcmath чтобы избежать работы с float, поэтому при запуске этого кода убедитесь, что расширение подключено в вашей конфигурации PHP.

class Money
{
    private const DECIMAL_SCALE = 2;

    private string $value;

    private function __construct(string $value)
    {
        $this->value = $this->normalize($value);
    }

    public static function createZero(): self
    {
        return new self('0');
    }

    public static function createFromString(string $value): self
    {
        return new self($value);
    }

    public function toString(): string
    {
        return $this->value;
    }

    public function add(Money $amount): Money
    {
        return self::createFromString(bcadd($this->value, $amount->value, self::DECIMAL_SCALE));
    }

    public function substract(Money $amount): Money
    {
        return self::createFromString(bcsub($this->value, $amount->value, self::DECIMAL_SCALE));
    }

    public function isZero(): bool
    {
        return $this->equals(self::createZero());
    }

    public function invertSign(): Money
    {
        $zero = self::createZero();
        
        return $zero->substract($this);
    }

    public function equals(Money $money): bool
    {
        return bccomp($this->value, $money->value, self::DECIMAL_SCALE) === 0;
    }

    private function normalize(string $value): string
    {
        if (($value === '') || !preg_match('/^(-)?[0-9]+(\\.[0-9]+)?$/', $value)) {
            throw new \LogicException("Неподдерживаемый формат денег: {$value}");
        }

        return bcadd('0', $value, self::DECIMAL_SCALE);
    }
}

Теперь мы можем передавать объекты Money по всему проекту, не переживая за их целостность, ведь проверки уже отработали в момент создания объекта.

Ещё один плюс использования Value Object в том, что переместив проверки внутрь объекта мы упрощаем тестирование.

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

Пример использования класса Money

public function transfer(Account $from, Account $to, Money $amount): void
{
    $from->writeBalance($from->getBalanceAmount()->substract($amount));
    $to->writeBalance($to->getBalanceAmount()->add($amount));
}

Плюсы Value Object

Подведём итоги, что мы получаем благодаря внедрению Value Object.

Повышаем надёжность — мы не ошибёмся забыв сделать проверку, она будет выполнена всегда;

Улучшаем читаемость — вызовы встроенных функций объекта выглядят более опрятно;

Упрощаем тестирование — вместо тестирования логики проверок везде, тестируем только один объект.