Статьи

Образец денег: правильный способ представлять пары единиц стоимости

Шаблон «Деньги», определенный Мартином Фаулером и опубликованный в «Шаблонах архитектуры корпоративных приложений», является отличным способом представления пар «единица стоимости». Он называется Money Pattern, потому что он возник в финансовом контексте, и мы проиллюстрируем его использование в основном в этом контексте с использованием PHP.


Я понятия не имею, как реализован PayPal, но я думаю, что это хорошая идея, чтобы взять его функциональность в качестве примера. Позвольте мне показать вам, что я имею в виду, моя учетная запись PayPal имеет две валюты: доллары США и евро. Он разделяет два значения, но я могу получать деньги в любой валюте, я вижу свою общую сумму в любой из двух валют и могу извлекать в любой из двух. Ради этого примера представьте, что мы извлекаем деньги в любой из валют, и автоматическое преобразование выполняется, если остаток этой конкретной валюты меньше того, что мы хотим перевести, но при этом в другой валюте еще достаточно денег. Кроме того, мы ограничим пример только двумя валютами.


Если бы я создал и использовал объект Account, я хотел бы инициализировать его номером учетной записи.

1
2
3
function testItCanCrateANewAccount() {
    $this->assertInstanceOf(«Account», new Account(123));
}

Это, очевидно, не удастся, потому что у нас еще нет класса Account.

1
2
3
class Account {
 
}

Что ж, запись в новый файл "Account.php" и требование его в тесте сделали его успешным. Тем не менее, все это делается только для того, чтобы нам было удобно с этой идеей. Далее я подумываю получить id аккаунта.

1
2
3
function testItCanCrateANewAccountWithId() {
    $this->assertEquals(123, (new Account(123))->getId());
}

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

01
02
03
04
05
06
07
08
09
10
11
12
13
class Account {
 
    private $id;
 
    function __construct($id) {
        $this->id = $id;
    }
 
    public function getId() {
        return $this->id;
    }
 
}

Тест проходит, и Account начинает выглядеть как настоящий класс.


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

01
02
03
04
05
06
07
08
09
10
11
12
13
14
private $account;
 
protected function setUp() {
    $this->account = new Account(123);
}
 
[…]
 
function testItCanHavePrimaryAndSecondaryCurrencies() {
    $this->account->setPrimaryCurrency(«EUR»);
    $this->account->setSecondaryCurrency(‘USD’);
 
    $this->assertEquals(array(‘primary’ => ‘EUR’, ‘secondary’ => ‘USD’), $this->account->getCurrencies());
}

Теперь вышеприведенный тест заставит нас написать следующий код.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
class Account {
 
    private $id;
    private $primaryCurrency;
    private $secondaryCurrency;
 
[…]
 
    function setPrimaryCurrency($currency) {
        $this->primaryCurrency = $currency;
    }
 
    function setSecondaryCurrency($currency) {
        $this->secondaryCurrency = $currency;
    }
 
    function getCurrencies() {
        return array(‘primary’ => $this->primaryCurrency, ‘secondary’ => $this->secondaryCurrency);
    }
 
}

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


Есть бесконечные причины, почему бы не представлять деньги как простую ценность. Вычисления с плавающей точкой? Кто-нибудь? А как насчет фракционных валют? Должны ли мы иметь 10, 100 или 1000 центов в какой-нибудь экзотической валюте? Ну, это еще одна проблема, которую мы должны будем избежать. Как насчет распределения неделимых центов?

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

UML

Денежный паттерн — это, по сути, класс, заключающий в себе сумму и валюту. Затем он определяет все математические операции над значением по отношению к валюте. "allocate()" — это специальная функция для распределения определенной суммы денег между двумя или более получателями.

Итак, как пользователь Money я бы хотел сделать это в тесте:

1
2
3
4
5
6
7
class MoneyTest extends PHPUnit_Framework_TestCase {
 
    function testWeCanCreateAMoneyObject() {
        $money = new Money(100, Currency::USD());
    }
 
}

Но это пока не сработает. Нам нужны и Money и Currency . Более того, нам нужна Currency а не Money . Это будет простой класс, поэтому я пока пропущу его тестирование. Я уверен, что IDE может сгенерировать большую часть кода для меня.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class Currency {
 
    private $centFactor;
    private $stringRepresentation;
 
    private function __construct($centFactor, $stringRepresentation) {
        $this->centFactor = $centFactor;
        $this->stringRepresentation = $stringRepresentation;
    }
 
    public function getCentFactor() {
        return $this->centFactor;
    }
 
    function getStringRepresentation() {
        return $this->stringRepresentation;
    }
 
    static function USD() {
        return new self(100, ‘USD’);
    }
 
    static function EUR() {
        return new self(100, ‘EUR’);
    }
 
}

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

Затем включите в тест два новых файла:

01
02
03
04
05
06
07
08
09
10
require_once ‘../Currency.php’;
require_once ‘../Money.php’;
 
class MoneyTest extends PHPUnit_Framework_TestCase {
 
    function testWeCanCreateAMoneyObject() {
        $money = new Money(100, Currency::USD());
    }
 
}

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

01
02
03
04
05
06
07
08
09
10
11
class Money {
 
    private $amount;
    private $currency;
 
    function __construct($amount, Currency $currency) {
        $this->amount = $amount;
        $this->currency = $currency;
    }
 
}

Обратите внимание: мы применяем тип Currency для второго параметра в нашем конструкторе. Это хороший способ избежать отправки нашими клиентами ненужной валюты.


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

1
2
3
4
5
6
7
function testItCanTellTwoMoneyObjectAreEqual() {
    $m1 = new Money(100, Currency::USD());
    $m2 = new Money(100, Currency::USD());
 
    $this->assertEquals($m1,$m2);
    $this->assertTrue($m1 == $m2);
}

Ну, это на самом деле проходит. Функция "assertEquals" может сравнивать два объекта, и даже встроенное условие равенства из PHP "==" говорит мне, чего я ожидаю. Ницца.

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

1
2
3
4
5
6
7
function testOneMoneyIsBiggerThanTheOther() {
    $m1 = new Money(200, Currency::USD());
    $m2 = new Money(100, Currency::USD());
 
    $this->assertGreaterThan($m2, $m1);
    $this->assertTrue($m1 > $m2);
}

Что приводит нас к …

1
2
3
4
5
6
7
function testOneMoneyIsLessThanTheOther() {
    $m1 = new Money(100, Currency::USD());
    $m2 = new Money(200, Currency::USD());
 
    $this->assertLessThan($m2, $m1);
    $this->assertTrue($m1 < $m2);
}

… тест, который проходит немедленно.


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

1
2
3
4
5
6
7
function testTwoMoneyObjectsCanBeAdded() {
    $m1 = new Money(100, Currency::USD());
    $m2 = new Money(200, Currency::USD());
    $sum = new Money(300, Currency::USD());
 
    $this->assertEquals($sum, $m1 + $m2);
}

Который терпит неудачу и говорит:

1
Object of class Money could not be converted to int

Хм. Это звучит довольно очевидно. На данный момент мы должны принять решение. Можно продолжить это упражнение с еще большей магией PHP, но этот подход в какой-то момент преобразует это руководство в чит-лист PHP вместо шаблона проектирования. Итак, давайте примем решение реализовать реальные методы сложения, вычитания и умножения денежных объектов.

1
2
3
4
5
6
7
function testTwoMoneyObjectsCanBeAdded() {
    $m1 = new Money(100, Currency::USD());
    $m2 = new Money(200, Currency::USD());
    $sum = new Money(300, Currency::USD());
 
    $this->assertEquals($sum, $m1->add($m2));
}

Этот тест также не проходит, но с ошибкой, сообщающей нам, что на Money нет метода "add" .

1
2
3
4
5
6
7
public function getAmount() {
    return $this->amount;
}
 
function add($other) {
    return new Money($this->amount + $other->getAmount(), $this->currency);
}

Чтобы суммировать два объекта Money , нам нужен способ получить сумму объекта, который мы передаем в качестве аргумента. Я предпочитаю писать метод получения, но установка переменной класса также будет приемлемым решением. Но что, если мы хотим добавить доллары к евро?

01
02
03
04
05
06
07
08
09
10
/**
 * @expectedException Exception
 * @expectedExceptionMessage Both Moneys must be of same currency
 */
function testItThrowsExceptionIfWeTryToAddTwoMoneysWithDifferentCurrency() {
    $m1 = new Money(100, Currency::USD());
    $m2 = new Money(100, Currency::EUR());
 
    $m1->add($m2);
}

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

01
02
03
04
05
06
07
08
09
10
11
12
13
public function getCurrency() {
    return $this->currency;
}
 
function add(Money $other) {
    $this->ensureSameCurrencyWith($other);
    return new Money($this->amount + $other->getAmount(), $this->currency);
}
 
private function ensureSameCurrencyWith(Money $other) {
    if ($this->currency != $other->getCurrency())
        throw new Exception(«Both Moneys must be of same currency»);
}

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

Вычитание и умножение очень похожи на сложение, поэтому вот код, и вы можете найти тесты в прилагаемом исходном коде.

01
02
03
04
05
06
07
08
09
10
11
function subtract(Money $other) {
    $this->ensureSameCurrencyWith($other);
    if ($other > $this)
        throw new Exception(«Subtracted money is more than what we have»);
    return new Money($this->amount — $other->getAmount(), $this->currency);
}
 
function multiplyBy($multiplier, $roundMethod = PHP_ROUND_HALF_UP) {
    $product = round($this->amount * $multiplier, 0, $roundMethod);
    return new Money($product, $this->currency);
}

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


У нас есть почти полный Money и Currency . Настало время ввести эти объекты в Account . Мы начнем с Currency и изменим наши тесты соответственно.

1
2
3
4
5
6
function testItCanHavePrimaryAndSecondaryCurrencies() {
    $this->account->setPrimaryCurrency(Currency::EUR());
    $this->account->setSecondaryCurrency(Currency::USD());
 
    $this->assertEquals(array(‘primary’ => Currency::EUR(), ‘secondary’ => Currency::USD()), $this->account->getCurrencies());
}

Из-за динамической типизации PHP, этот тест проходит без проблем. Однако я хотел бы заставить методы в Account использовать объекты Currency и ничего больше не принимать. Это не обязательно, но я нахожу такие подсказки типов чрезвычайно полезными, когда кто-то еще должен понимать наш код.

1
2
3
4
5
6
7
function setPrimaryCurrency(Currency $currency) {
    $this->primaryCurrency = $currency;
}
 
function setSecondaryCurrency(Currency $currency) {
    $this->secondaryCurrency = $currency;
}

Теперь для любого, кто впервые читает этот код, очевидно, что Account работает с Currency .


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

1
2
3
4
5
6
7
function testAccountCanDepositMoney() {
    $this->account->setPrimaryCurrency(Currency::EUR());
    $money = new Money(100, Currency::EUR());
    $this->account->deposit($money);
 
    $this->assertEquals($money, $this->account->getPrimaryBalance());
}

Это заставит нас писать довольно много кода реализации.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class Account {
 
    private $id;
    private $primaryCurrency;
    private $secondaryCurrency;
    private $secondaryBalance;
    private $primaryBalance;
 
    function getSecondaryBalance() {
        return $this->secondaryBalance;
    }
 
    function getPrimaryBalance() {
        return $this->primaryBalance;
    }
 
    function __construct($id) {
        $this->id = $id;
    }
 
    […]
 
    function deposit(Money $money) {
        $this->primaryCurrency == $money->getCurrency() ?
    }
 
}

ХОРОШО-ХОРОШО. Я знаю, я написал больше, чем было абсолютно необходимо для производства. Но я не хочу утомлять вас смертными шагами, и я также уверен, что код для secondaryBalance будет работать правильно. Он был почти полностью сгенерирован IDE. Я даже пропущу тестирование. В то время как этот код проходит наш тест, мы должны спросить себя, что происходит, когда мы делаем последующие депозиты? Мы хотим, чтобы наши деньги были добавлены к предыдущему балансу.

1
2
3
4
5
6
7
8
function testSubsequentDepositsAddUpTheMoney() {
    $this->account->setPrimaryCurrency(Currency::EUR());
    $money = new Money(100, Currency::EUR());
    $this->account->deposit($money);
    $this->account->deposit($money);
 
    $this->assertEquals($money->multiplyBy(2), $this->account->getPrimaryBalance());
}

Ну, это не удалось. Поэтому мы должны обновить наш производственный код.

1
2
3
4
5
6
7
8
9
function deposit(Money $money) {
    if ($this->primaryCurrency == $money->getCurrency()){
        $this->primaryBalance = $this->primaryBalance ?
        $this->primaryBalance = $this->primaryBalance->add($money);
    }else {
        $this->secondaryBalance = $this->secondaryBalance ?
        $this->secondaryBalance = $this->secondaryBalance->add($money);
    }
}

Это намного лучше. Вероятно, мы закончили с методом deposit и мы можем продолжить withdraw .

1
2
3
4
5
6
7
8
9
function testAccountCanWithdrawMoneyOfSameCurrency() {
    $this->account->setPrimaryCurrency(Currency::EUR());
    $money = new Money(100, Currency::EUR());
    $this->account->deposit($money);
    $this->account->withdraw(new Money(70, Currency::EUR()));
 
    $this->assertEquals(new Money(30, Currency::EUR()), $this->account->getPrimaryBalance());
 
}

Это всего лишь простой тест. Решение также простое.

1
2
3
4
5
function withdraw(Money $money) {
    $this->primaryCurrency == $money->getCurrency() ?
        $this->primaryBalance = $this->primaryBalance->subtract($money) :
        $this->secondaryBalance = $this->secondaryBalance->subtract($money);
}

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

01
02
03
04
05
06
07
08
09
10
11
/**
 * @expectedException Exception
 * @expectedExceptionMessage This account has no currency USD
 */
 
function testThrowsExceptionForInexistentCurrencyOnWithdraw() {
    $this->account->setPrimaryCurrency(Currency::EUR());
    $money = new Money(100, Currency::EUR());
    $this->account->deposit($money);
    $this->account->withdraw(new Money(70, Currency::USD()));
}

Это также заставит нас проверять наши валюты.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
function withdraw(Money $money) {
    $this->validateCurrencyFor($money);
    $this->primaryCurrency == $money->getCurrency() ?
        $this->primaryBalance = $this->primaryBalance->subtract($money) :
        $this->secondaryBalance = $this->secondaryBalance->subtract($money);
}
 
private function validateCurrencyFor(Money $money) {
    if (!in_array($money->getCurrency(), $this->getCurrencies()))
            throw new Exception(
                sprintf(
                    ‘This account has no currency %s’,
                    $money->getCurrency()->getStringRepresentation()
                )
            );
}

Но что, если мы хотим вывести больше, чем имеем? Этот случай уже был рассмотрен, когда мы осуществили вычитание на Money . Вот тест, который доказывает это.

01
02
03
04
05
06
07
08
09
10
/**
 * @expectedException Exception
 * @expectedExceptionMessage Subtracted money is more than what we have
 */
function testItThrowsExceptionIfWeTryToSubtractMoreMoneyThanWeHave() {
    $this->account->setPrimaryCurrency(Currency::EUR());
    $money = new Money(100, Currency::EUR());
    $this->account->deposit($money);
    $this->account->withdraw(new Money(150, Currency::EUR()));
}

Когда мы работаем с несколькими валютами, одной из самых сложных вещей является обмен между ними. Прелесть этого шаблона проектирования в том, что он позволяет нам несколько упростить эту проблему, изолируя и инкапсулируя ее в своем собственном классе. Хотя логика в классе Exchange может быть очень сложной, ее использование становится намного проще. Ради этого урока давайте представим, что у нас есть только очень базовая логика Exchange . 1 евро = 1,5 доллара США.

01
02
03
04
05
06
07
08
09
10
11
class Exchange {
 
    function convert(Money $money, Currency $toCurrency) {
        if ($toCurrency == Currency::EUR() && $money->getCurrency() == Currency::USD())
            return new Money($money->multiplyBy(0.67)->getAmount(), $toCurrency);
        if ($toCurrency == Currency::USD() && $money->getCurrency() == Currency::EUR())
            return new Money($money->multiplyBy(1.5)->getAmount(), $toCurrency);
        return $money;
    }
 
}

Если мы конвертируем из EUR в USD, мы умножаем значение на 1,5, если мы конвертируем из USD в EUR, мы делим значение на 1,5, в противном случае мы предполагаем, что конвертируем две валюты одного типа, поэтому мы ничего не делаем и просто возвращаем деньги , Конечно, в действительности это был бы намного более сложный класс.

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

01
02
03
04
05
06
07
08
09
10
11
12
13
14
function testItConvertsMoneyFromTheOtherCurrencyWhenWeDoNotHaveEnoughInTheCurrentOne() {
    $this->account->setPrimaryCurrency(Currency::USD());
    $money = new Money(100, Currency::USD());
    $this->account->deposit($money);
 
    $this->account->setSecondaryCurrency(Currency::EUR());
    $money = new Money(100, Currency::EUR());
    $this->account->deposit($money);
 
    $this->account->withdraw(new Money(200, Currency::USD()));
 
    $this->assertEquals(new Money(0, Currency::USD()), $this->account->getPrimaryBalance());
    $this->assertEquals(new Money(34, Currency::EUR()), $this->account->getSecondaryBalance());
}

Мы устанавливаем основную валюту нашего счета в долларах США и вносим один доллар. Затем мы устанавливаем дополнительную валюту в евро и вносим один евро. Тогда мы снимаем два доллара. Наконец, мы ожидаем остаться с нулевым долларом и 0,34 евро. Конечно, этот тест вызывает исключение, поэтому мы должны реализовать решение этой дилеммы.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
function withdraw(Money $money) {
    $this->validateCurrencyFor($money);
    if ($this->primaryCurrency == $money->getCurrency()) {
        if( $this->primaryBalance >= $money ) {
            $this->primaryBalance = $this->primaryBalance->subtract($money);
        }else{
            $ourMoney = $this->primaryBalance->add($this->secondaryToPrimary());
            $remainingMoney = $ourMoney->subtract($money);
            $this->primaryBalance = new Money(0, $this->primaryCurrency);
            $this->secondaryBalance = (new Exchange())->convert($remainingMoney, $this->secondaryCurrency);
        }
 
    } else {
        $this->secondaryBalance = $this->secondaryBalance->subtract($money);
    }
}
 
private function secondaryToPrimary() {
    return (new Exchange())->convert($this->secondaryBalance, $this->primaryCurrency);
}

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

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

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

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
/**
 * @expectedException Exception
 * @expectedExceptionMessage Subtracted money is more than what we have
 */
function testItThrowsExceptionIfWeTryToSubtractMoreMoneyThanWeHave() {
    $this->account->setPrimaryCurrency(Currency::EUR());
    $money = new Money(100, Currency::EUR());
    $this->account->deposit($money);
 
    $this->account->setSecondaryCurrency(Currency::USD());
    $money = new Money(0, Currency::USD());
    $this->account->deposit($money);
 
 
    $this->account->withdraw(new Money(150, Currency::EUR()));
}

Последний метод, который нам нужно реализовать на Money это allocate . Это логика, которая решает, что делать при делении денег между различными счетами, которые нельзя сделать точно. Например, если у нас есть 0,10 цента, и мы хотим распределить их между двумя счетами в пропорции 30-70 процентов, это легко. Один счет получит три цента, а остальные семь. Однако, если мы хотим сделать то же самое соотношение 30-70 пяти центов, у нас есть проблема. Точное распределение будет 1,5 цента на одном счете и 3,5 на другом. Но мы не можем делить центы, поэтому мы должны реализовать наш собственный алгоритм для распределения денег.

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

ассигнования

И тест, чтобы доказать нашу точку зрения ниже.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
function testItCanAllocateMoneyBetween2Accounts() {
    $a1 = $this->anAccount();
    $a2 = $this->anAccount();
    $money = new Money(5, Currency::USD());
    $money->allocate($a1, $a2, 30, 70);
 
    $this->assertEquals(new Money(2, Currency::USD()), $a1->getPrimaryBalance());
    $this->assertEquals(new Money(3, Currency::USD()), $a2->getPrimaryBalance());
}
 
private function anAccount() {
    $account = new Account(1);
    $account->setPrimaryCurrency(Currency::USD());
    $account->deposit(new Money(0, Currency::USD()));
    return $account;
}

Мы просто создаем объект Money с пятью центами и двумя счетами. Мы вызываем allocate и ожидаем, что два-три значения будут в двух аккаунтах. Мы также создали вспомогательный метод для быстрого создания учетных записей. Как и ожидалось, тест не пройден, но мы можем легко его пройти.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
function allocate(Account $a1, Account $a2, $a1Percent, $a2Percent) {
    $exactA1Balance = $this->amount * $a1Percent / 100;
    $exactA2Balance = $this->amount * $a2Percent / 100;
 
    $oneCent = new Money(1, $this->currency);
    while ($this->amount > 0) {
        if ($a1->getPrimaryBalance()->getAmount() < $exactA1Balance) {
            $a1->deposit($oneCent);
            $this->amount—;
        }
        if ($this->amount <= 0)
            break;
        if ($a2->getPrimaryBalance()->getAmount() < $exactA2Balance) {
            $a2->deposit($oneCent);
            $this->amount—;
        }
    }
}

Ну, не самый простой код, но он работает правильно, как показывает наш тест. Единственное, что мы можем сделать с этим кодом, это уменьшить небольшое дублирование внутри цикла while.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
function allocate(Account $a1, Account $a2, $a1Percent, $a2Percent) {
    $exactA1Balance = $this->amount * $a1Percent / 100;
    $exactA2Balance = $this->amount * $a2Percent / 100;
 
    while ($this->amount > 0) {
        $this->allocateTo($a1, $exactA1Balance);
        if ($this->amount <= 0)
            break;
        $this->allocateTo($a2, $exactA2Balance);
    }
}
 
private function allocateTo($account, $exactBalance) {
    if ($account->getPrimaryBalance()->getAmount() < $exactBalance) {
        $account->deposit(new Money(1, $this->currency));
        $this->amount—;
    }
}

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

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

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

Другой вариант использования — это когда у вас есть картографическое приложение, и вы хотите представить расстояния между точками. Вы можете легко использовать этот шаблон для переключения между метрическими или имперскими измерениями. Когда вы работаете с простыми единицами, вы можете отбросить объект Exchange и реализовать простую логику преобразования внутри вашего объекта «Деньги».

Итак, я надеюсь, что вам понравился этот урок, и мне не терпится услышать о различных способах использования этой концепции. Спасибо за чтение.