Паттерн Money.

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

Задача. Нужно считать деньги, выполняя с ними обычные математические операции.
Проблема. PHP как и многие языки страдает известной проблемой двоичного представления чисел. Для приведения десятичного числа к двоичному внутреннему используется деление на 2, соответственно числа вроде 1/3 округляются до энного знака.

Паттерн Money реализует работу с деньгами: математические операции (+, -, *, /), сравнения (>, <, >=, <=, ==, <>) и учёт курса обмена.

Реализуем ООП-шный интерфейс, а для математических операций используем расширение bc. Времени и сил у меня сейчас мало, поэтому пока версия без учёта курса.

namespace Kiss;

class Money {
    const OPERAND_GT = '>';
    const OPERAND_LT = '<';
    const OPERAND_EQ = '==';
    const OPERAND_GT_EQ = '>=';
    const OPERAND_LT_EQ = '<=';
    const OPERAND_NOT_EQ = '<>';

    protected $amount;
    public $scale = 2;

    /**
     * @param mixed
     */
    public function __construct($amount) {
        $this->amount = str_replace(',', '.', $amount);
    }
    /**
     *
     * @param Money $data
     * @return \Kiss\Money
     */
    public function converToMoney($data) {
        if ($data instanceof Money) {
            return $data;
        } else {
            return new Money($data);
        }
    }
    public function getAmmount() {
        return $this->amount;
    }
    public function add($data) {
        return new Money(bcadd($this->getAmmount(), $this->converToMoney($data)->getAmmount(), $this->scale));
    }
    public function subtract($data) {
        return new Money(bcsub($this->getAmmount(), $this->converToMoney($data)->getAmmount(), $this->scale));
    }
    public function multiply($data) {
        $data = str_replace(',', '.', $data);
        return new Money(bcmul($this->getAmmount(), $data, $this->scale));
    }
    public function __toString() {
        return $this->getAmmount();
    }
    public function divide($data) {
        $data = str_replace(',', '.', $data);
        if (floatval($data)) {
            return new Money(bcdiv($this->getAmmount(), $data, $this->scale));
        } else {
            throw new \LogicException('Devide by zero');
        }
    }
    /**
     * @param mixed $data
     * @param sting $operand
     * @return boolean
     * @throws \LogicException
     */
    public function compare($data, $operand) {
        $current = (float) $this->getAmmount();
        $data = (float) $this->converToMoney($data)->getAmmount();
        switch ($operand) {
            case self::OPERAND_GT:
                return ($current > $data);
                break;
            case self::OPERAND_LT:
                return ($current < $data);
                break;
            case self::OPERAND_EQ:
                return ($current == $data);
                break;
            case self::OPERAND_GT_EQ:
                return ($current >= $data);
                break;
            case self::OPERAND_LT_EQ:
                return ($current <= $data);
                break;
            case self::OPERAND_NOT_EQ:
                return ($current <> $data);
                break;
            default :
                throw new \LogicException('Unknow operand');
                break;
        }
    }
}

Пару пояснений. В конструкторе заменяем любимый русский разделитель в виде запятой на православный — точку.
Параметр $scale указывает точность операций иначе bc функции будут использовать дефолтный.
Все входные цифры принудительно оборачиваем в класс \Kiss\Money, основная выгода будет когда добавим так курсы.
Функции bc* на вход принимают  строчный параметр, поэтому передаём им строки, можно было бы просто $this, а там уже дёрнулся __toString, но это уже излишняя магия.

Для сравнения используем метод compare, в который передаём оператор. Я долго думал о логике что с чем сравнивать, в итоге решил, что логично чтобы было более человечно и объект говорил каков он относительно переданного параметра.
$money->compare(2, ‘>’); // говорит Да, если $money больше двух.

Для лучшего понимаю привожу юнит тест.

<?php
namespace Kiss;

require_once dirname(__FILE__) . '/../lib/Kiss/Money.php';
class MoneyTest extends \PHPUnit_Framework_TestCase {
    public function test__toString() {
        $money = new Money('12,34');
        $this->assertEquals('12.34', (string) $money);
    }
    public function testAdd() {
        $money = new Money('10,11');
        $finalMoney = $money->add('2,23');
        $this->assertEquals('12.34', (string) $finalMoney);
    }
    public function testSubstract() {
        $money = new Money('10,45');
        $finalMoney = $money->add(new Money('21,55'));
        $this->assertEquals('32.00', (string) $finalMoney);
    }
    public function testMultiply() {
        $money = new Money('12,34');
        $finalMoney = $money->multiply(100);
        $this->assertEquals('1234.00', (string) $finalMoney);
    }
    public function testDivide() {
        $money = new Money('12,34');
        $finalMoney = $money->divide(2);
        $this->assertEquals('6.17', (string) $finalMoney);
    }
    /**
     * @expectedException \LogicException
     */
    public function testDivideByZero() {
        $money = new Money('12,34');
        $finalMoney = $money->divide(0);
    }
    public function testCompareGt() {
        $money = new Money('12,34');
        $this->assertFalse($money->compare(12.35, Money::OPERAND_GT));
        $this->assertFalse($money->compare(12.34, Money::OPERAND_GT));
        $this->assertTrue($money->compare(12.33, Money::OPERAND_GT));
    }
    public function testCompareLt() {
        $money = new Money('12,34');
        $this->assertTrue($money->compare(12.35, Money::OPERAND_LT));
        $this->assertFalse($money->compare(12.34, Money::OPERAND_LT));
        $this->assertFalse($money->compare(12.33, Money::OPERAND_LT));
    }
    public function testCompareEqual() {
        $money = new Money('12,34');
        $this->assertFalse($money->compare(12.35, Money::OPERAND_EQ));
        $this->assertTrue($money->compare(12.34, Money::OPERAND_EQ));
        $this->assertFalse($money->compare(12.33, Money::OPERAND_EQ));
    }
    public function testCompareGtOrEqual() {
        $money = new Money('12,34');
        $this->assertFalse($money->compare(12.35, Money::OPERAND_GT_EQ));
        $this->assertTrue($money->compare(12.34, Money::OPERAND_GT_EQ));
        $this->assertTrue($money->compare(12.33, Money::OPERAND_GT_EQ));
    }
    public function testCompareLtOrEqual() {
        $money = new Money('12,34');
        $this->assertTrue($money->compare(12.35, Money::OPERAND_LT_EQ));
        $this->assertTrue($money->compare(12.34, Money::OPERAND_LT_EQ));
        $this->assertFalse($money->compare(12.33, Money::OPERAND_LT_EQ));
    }
    public function testCompareNotEqual() {
        $money = new Money('12,34');
        $this->assertTrue($money->compare(12.35, Money::OPERAND_NOT_EQ));
        $this->assertFalse($money->compare(12.34, Money::OPERAND_NOT_EQ));
        $this->assertTrue($money->compare(12.33, Money::OPERAND_NOT_EQ));
    }
    /**
     * @expectedException \LogicException
     */
    public function testCompareUnknowOperand() {
        $money = new Money('12,34');
        $this->assertTrue($money->compare(12.35, 'unknow operand'));
    }
}

Всё.Надеюсь, в этот раз пропаду не на долго и добью этот паттерн. Удачи Вам с деньгами.

 

Паттерн Money.: 7 комментариев

  1. karneds


    public function divide($data) {
    $factor = str_replace(',', '.', $data);
    ...

    здесь наверное должно быть так $data = str_replace(‘,’, ‘.’, $data); ?

    1. AmdY Автор записи

      Угу, спасибо. Обидно, что тестами не выловил этот баг, нужно ещё тренироваться и тренироваться.

  2. fluid

    Смотритрится ужасно… Вот это в католическом срр, смотрелось бы очень и очень хорошо, за счет перегрузки операторов…

    PS почему нет умножения на 100, а работа с N знаков, после запятой? Тогда никаких проблем быть не должно…

  3. keltanas

    Есть такой класс Zend_Currency. Тот же Money из популярного фреймворка )) Только его еще можно и конвертировать научить ;-)

    1. AmdY Автор записи

      Ага, благодарю, пропустил его появление в ZF 1.5. К сожалению, наш метод compare не сошёлся, а так весьма похожи.

  4. Дмитрий

    >> В конструкторе заменяем любимый русский разделитель в виде запятой на православный — точку.

    Неплохо подмечено, не много ли возложили на данный класс и будет ли продолжение?
    С нетерпением слежу за темой классификации валюты по православным неправославным морфизмам.

    >> Параметр $scale указывает точность операций иначе bc функции будут использовать дефолтный.

    Это PHP использует бинарную библиотеку математики Linux? Очень интересно.

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *