Все знают вредные паттерны 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')); } }
Всё.Надеюсь, в этот раз пропаду не на долго и добью этот паттерн. Удачи Вам с деньгами.
public function divide($data) {
$factor = str_replace(',', '.', $data);
...
здесь наверное должно быть так $data = str_replace(‘,’, ‘.’, $data); ?
Угу, спасибо. Обидно, что тестами не выловил этот баг, нужно ещё тренироваться и тренироваться.
Смотритрится ужасно… Вот это в католическом срр, смотрелось бы очень и очень хорошо, за счет перегрузки операторов…
PS почему нет умножения на 100, а работа с N знаков, после запятой? Тогда никаких проблем быть не должно…
Есть такой класс Zend_Currency. Тот же Money из популярного фреймворка )) Только его еще можно и конвертировать научить 😉
Ага, благодарю, пропустил его появление в ZF 1.5. К сожалению, наш метод compare не сошёлся, а так весьма похожи.
Именно такого метода нет, но есть другие возможности сравнения.
http://framework.zend.com/manual/en/zend.currency.calculation.html
>> В конструкторе заменяем любимый русский разделитель в виде запятой на православный — точку.
Неплохо подмечено, не много ли возложили на данный класс и будет ли продолжение?
С нетерпением слежу за темой классификации валюты по православным неправославным морфизмам.
>> Параметр $scale указывает точность операций иначе bc функции будут использовать дефолтный.
Это PHP использует бинарную библиотеку математики Linux? Очень интересно.