1. Code
  2. PHP

El patrón del dinero: La forma correcta de representar pares de unidades de valor

Scroll to top
17 min read

Spanish (Español) translation by Steven (you can also view the original English article)

El patrón del dinero o "Money Pattern", definido por Martin Fowler y publicado en "Patterns of Enterprise Application Architecture", es una excelente manera de representar pares de unidades de valor. Se llama Money Pattern porque surgió en un contexto financiero y vamos a ilustrar su uso principalmente en este contexto usando PHP.


Una cuenta similar a PayPal

No tengo idea de cómo se implementa PayPal, pero creo que es una buena idea tomar su funcionalidad como ejemplo. Permítanme mostrarles lo que quiero decir, mi cuenta de PayPal tiene dos monedas: dólares estadounidenses y euros. Mantiene los dos valores separados, pero puedo recibir dinero en cualquier moneda, puedo ver mi monto total en cualquiera de las dos monedas y puedo extraer en cualquiera de las dos. Por el bien de este ejemplo, imagina que extraemos en cualquiera de las monedas y se realiza la conversión automática si el saldo de esa moneda específica es menor de lo que queremos transferir pero aún hay suficiente dinero en la otra moneda. Además, limitaremos el ejemplo a solo dos monedas.


Obtener una cuenta

Si tuviera que crear y usar un objeto "Account", me gustaría inicializarlo con un número de cuenta.

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

Obviamente, esto fallará porque todavía no tenemos una clase "Account".

1
class Account {
2
3
}

Bueno, escribir eso en un nuevo archivo "Account.php" y requerirlo en la prueba, lo hizo pasar. Sin embargo, todo esto se está haciendo solo para sentirnos cómodos con la idea. A continuación, estoy pensando en obtener la identificación (id) de la cuenta.

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

De hecho, cambié la prueba anterior por esta. No hay razón para quedarse con el primero. Vivió su vida, lo que significa que me obligó a pensar en la clase Account y crearla. Ahora podemos continuar.

1
class Account {
2
3
	private $id;
4
5
	function __construct($id) {
6
		$this->id = $id;
7
	}
8
9
	public function getId() {
10
		return $this->id;
11
	}
12
13
}

La prueba está pasando y Account empieza a parecer una clase real.


Monedas

Según nuestra analogía con PayPal, es posible que queramos definir una moneda principal y una secundaria para nuestra cuenta.

1
private $account;
2
3
protected function setUp() {
4
	$this->account = new Account(123);
5
}
6
7
[...]
8
9
function testItCanHavePrimaryAndSecondaryCurrencies() {
10
	$this->account->setPrimaryCurrency("EUR");
11
	$this->account->setSecondaryCurrency('USD');
12
13
	$this->assertEquals(array('primary' => 'EUR', 'secondary' => 'USD'), $this->account->getCurrencies());
14
}

Ahora la prueba anterior nos obligará a escribir el siguiente código.

1
class Account {
2
3
	private $id;
4
	private $primaryCurrency;
5
	private $secondaryCurrency;
6
7
[...]
8
9
	function setPrimaryCurrency($currency) {
10
		$this->primaryCurrency = $currency;
11
	}
12
13
	function setSecondaryCurrency($currency) {
14
		$this->secondaryCurrency = $currency;
15
	}
16
17
	function getCurrencies() {
18
		return array('primary' => $this->primaryCurrency, 'secondary' => $this->secondaryCurrency);
19
	}
20
21
}

Por el momento, mantenemos la moneda como un simple "string". Esto puede cambiar en el futuro, pero todavía no lo hemos logrado.


Dame el dinero

Hay un sinfín de razones por las que no se debe representar el dinero como un valor simple. ¿Cálculos de coma flotante? ¿Alguien? ¿Qué pasa con las fracciones de moneda? ¿Deberíamos tener 10, 100 o 1000 centavos en alguna moneda exótica? Bueno, este es otro problema que tendremos que evitar. ¿Qué pasa con la asignación de centavos indivisibles?

Hay demasiados problemas exóticos cuando se trabaja con dinero para escribirlos en código, así que pasaremos directamente a la solución, el "Patrón del dinero". Este es un patrón bastante simple, con grandes ventajas y muchos casos de uso, lejos del dominio financiero. Siempre que tengas que representar un par de unidades de valor, probablemente deberías usar este patrón.

UMLUMLUML

El patrón de dinero es básicamente una clase que encapsula una cantidad y una moneda. Luego define todas las operaciones matemáticas sobre el valor con respecto a la moneda. "allocate()" es una función especial para distribuir una cantidad específica de dinero entre dos o más destinatarios.

Entonces, como usuario de Money, me gustaría poder hacer esto en una prueba:

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

Pero eso todavía no funcionará. Necesitamos tanto dinero (Money) como moneda (Currency). Aún más, necesitamos Currency antes que Money. Esta será una clase simple, por lo que omitiré probarla por ahora. Estoy bastante seguro de que el IDE puede generar la mayor parte del código por mí.

1
class Currency {
2
3
	private $centFactor;
4
	private $stringRepresentation;
5
6
	private function __construct($centFactor, $stringRepresentation) {
7
		$this->centFactor = $centFactor;
8
		$this->stringRepresentation = $stringRepresentation;
9
	}
10
11
	public function getCentFactor() {
12
		return $this->centFactor;
13
	}
14
15
	function getStringRepresentation() {
16
		return $this->stringRepresentation;
17
	}
18
19
	static function USD() {
20
		return new self(100, 'USD');
21
	}
22
23
	static function EUR() {
24
		return new self(100, 'EUR');
25
	}
26
27
}

Eso es suficiente para nuestro ejemplo. Tenemos dos funciones estáticas para las monedas USD y EUR. En una aplicación real, probablemente tendríamos un constructor general con un parámetro y cargaríamos todas las monedas desde una tabla de base de datos o, mejor aún, desde un archivo de texto.

A continuación, incluye los dos archivos nuevos en la prueba:

1
require_once '../Currency.php';
2
require_once '../Money.php';
3
4
class MoneyTest extends PHPUnit_Framework_TestCase {
5
6
	function testWeCanCreateAMoneyObject() {
7
		$money = new Money(100, Currency::USD());
8
	}
9
10
}

Esta prueba aún falla, pero al menos ahora esto puede encontrar Currency. Seguimos con una implementación mínima de Money. Un poco más de lo que esta prueba requiere estrictamente ya que, nuevamente, es principalmente código generado automáticamente.

1
class Money {
2
3
	private $amount;
4
	private $currency;
5
6
	function __construct($amount, Currency $currency) {
7
		$this->amount = $amount;
8
		$this->currency = $currency;
9
	}
10
11
}

Ten en cuenta que aplicamos el tipo Currency para el segundo parámetro en nuestro constructor. Esta es una buena manera de evitar que nuestros clientes envíen basura como moneda.


Comparando el dinero

Lo primero que me vino a la mente después de tener el objeto mínimo en funcionamiento fue que tendré que comparar los objetos monetarios de alguna manera. Entonces recordé que PHP es bastante inteligente cuando se trata de comparar objetos, así que escribí esta prueba.

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

Bueno, eso realmente pasa. La función "assertEquals" puede comparar los dos objetos e incluso la condición de igualdad incorporada de PHP "==" me dice lo que espero. Genial.

Pero, ¿qué pasa si nos interesa que uno sea más grande que el otro? Para mi sorpresa aún mayor, la siguiente prueba también pasa sin problemas.

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

Lo que nos lleva a...

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

...una prueba que pasa de inmediato.


Más, Menos, Multiplicar

Al ver tanta magia de PHP funcionando realmente con las comparaciones, no pude resistirme a probar esta.

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

Que falla y dice:

1
Object of class Money could not be converted to int

Mmm. Eso suena bastante obvio. En este punto tenemos que tomar una decisión. Es posible continuar este ejercicio con aún más magia PHP, pero este enfoque, en algún momento, transformará este tutorial en una hoja de referencia PHP en lugar de un patrón de diseño. Entonces, tomemos la decisión de implementar los métodos reales para sumar, restar y multiplicar objetos de dinero.

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

Esta prueba también falla, pero con un error que nos indica que no hay un método "add" en Money.

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

Para resumir dos objetos Money, necesitamos una forma de obtener la cantidad del objeto que pasamos como argumento. Prefiero escribir un captador, pero configurar la variable de clase para que sea pública también sería una solución aceptable. Pero, ¿y si queremos agregar dólares a euros?

1
/**

2
 * @expectedException Exception

3
 * @expectedExceptionMessage Both Moneys must be of same currency

4
 */
5
function testItThrowsExceptionIfWeTryToAddTwoMoneysWithDifferentCurrency() {
6
	$m1 = new Money(100, Currency::USD());
7
	$m2 = new Money(100, Currency::EUR());
8
9
	$m1->add($m2);
10
}

Hay varias formas de realizar operaciones en objetos Money con diferentes monedas. Lanzaremos una excepción y la esperaremos en la prueba. Alternativamente, podríamos implementar un mecanismo de conversión de moneda en nuestra aplicación, llamarlo, convertir ambos objetos Money en alguna moneda predeterminada y compararlos. O, si tuviéramos un algoritmo de conversión de moneda más sofisticado, siempre podríamos convertir de una a otra y comparar en esa moneda convertida. La cuestión es que cuando se produce la conversión, se deben considerar las tarifas de conversión y las cosas se pondrán bastante complicadas. Así que arrojemos esa excepción y continuemos.

1
public function getCurrency() {
2
	return $this->currency;
3
}
4
5
function add(Money $other) {
6
	$this->ensureSameCurrencyWith($other);
7
	return new Money($this->amount + $other->getAmount(), $this->currency);
8
}
9
10
private function ensureSameCurrencyWith(Money $other) {
11
	if ($this->currency != $other->getCurrency())
12
		throw new Exception("Both Moneys must be of same currency");
13
}

Eso es mejor. Hacemos una verificación para ver si las monedas son diferentes y lanzamos una excepción. Ya lo escribí como un método privado separado, porque sé que también lo necesitaremos en las otras operaciones matemáticas.

La resta y la multiplicación son muy similares a la suma, así que aquí está el código y puedes encontrar las pruebas en el código fuente adjunto.

1
function subtract(Money $other) {
2
	$this->ensureSameCurrencyWith($other);
3
	if ($other > $this)
4
		throw new Exception("Subtracted money is more than what we have");
5
	return new Money($this->amount - $other->getAmount(), $this->currency);
6
}
7
8
function multiplyBy($multiplier, $roundMethod = PHP_ROUND_HALF_UP) {
9
	$product = round($this->amount * $multiplier, 0, $roundMethod);
10
	return new Money($product, $this->currency);
11
}

Con la resta, tenemos que asegurarnos de tener suficiente dinero y con la multiplicación, debemos tomar acciones para redondear las cosas hacia arriba o hacia abajo para que la división (multiplicación con números menores a uno) no produzca "medio centavo". Mantenemos nuestra cantidad en centavos, el factor más bajo posible de la moneda. No podemos dividirlo más.


Introducción de moneda a nuestra cuenta

Tenemos Money y Currency casi completo. Es hora de presentar estos objetos a Account. Comenzaremos con Currency y cambiaremos nuestras pruebas en consecuencia.

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

Debido a la naturaleza de escritura dinámica de PHP, esta prueba pasa sin problemas. Sin embargo, me gustaría forzar a los métodos en Account para usar objetos Currency y no acepto nada más. Esto no es obligatorio, pero encuentro este tipo de bisagras de tipo extremadamente útiles cuando alguien más necesita entender nuestro código.

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

Ahora es obvio para cualquiera que lea este código por primera vez que Account funciona con Currency.


Introducción de dinero a nuestra cuenta

Las dos acciones básicas que debe proporcionar cualquier cuenta son: depositar, es decir, agregar dinero a una cuenta, y retirar, es decir, eliminar dinero de una cuenta. El depósito tiene una fuente y el retiro tiene un destino diferente a nuestra cuenta corriente. No entraremos en detalles sobre cómo implementar estas transacciones, solo nos concentraremos en implementar los efectos que tienen en nuestra cuenta. Entonces, podemos imaginar una prueba como esta para depositar.

1
function testAccountCanDepositMoney() {
2
	$this->account->setPrimaryCurrency(Currency::EUR());
3
	$money = new Money(100, Currency::EUR()); //That's 1 EURO

4
	$this->account->deposit($money);
5
6
	$this->assertEquals($money, $this->account->getPrimaryBalance());
7
}

Esto nos obligará a escribir bastante código de implementación.

1
class Account {
2
3
	private $id;
4
	private $primaryCurrency;
5
	private $secondaryCurrency;
6
	private $secondaryBalance;
7
	private $primaryBalance;
8
9
	function getSecondaryBalance() {
10
		return $this->secondaryBalance;
11
	}
12
13
	function getPrimaryBalance() {
14
		return $this->primaryBalance;
15
	}
16
17
	function __construct($id) {
18
		$this->id = $id;
19
	}
20
21
	[...]
22
23
	function deposit(Money $money) {
24
		$this->primaryCurrency == $money->getCurrency() ? $this->primaryBalance = $money : $this->secondaryBalance = $money;
25
	}
26
27
}

BIEN BIEN. Lo sé, escribí más de lo absolutamente necesario para producción. Pero no quiero aburrirte hasta la muerte con pequeños pasos y también estoy bastante seguro de que el código de balance secundario (secondaryBalance) funcionará correctamente. Fue generado casi en su totalidad por el IDE. Incluso me saltaré la prueba. Si bien este código hace que nuestra prueba pase, tenemos que preguntarnos qué sucede cuando hacemos depósitos posteriores. Queremos que nuestro dinero se sume al saldo anterior.

1
function testSubsequentDepositsAddUpTheMoney() {
2
	$this->account->setPrimaryCurrency(Currency::EUR());
3
	$money = new Money(100, Currency::EUR()); //That's 1 EURO

4
	$this->account->deposit($money); //One euro in the account

5
	$this->account->deposit($money); //Two euros in the account

6
7
	$this->assertEquals($money->multiplyBy(2), $this->account->getPrimaryBalance());
8
}

Bueno, eso falla. Entonces tenemos que actualizar nuestro código de producción.

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

Esto es mucho mejor. Probablemente hayamos terminado con el método de depósito (deposit) y podemos continuar con el de retiro (withdraw).

1
function testAccountCanWithdrawMoneyOfSameCurrency() {
2
	$this->account->setPrimaryCurrency(Currency::EUR());
3
	$money = new Money(100, Currency::EUR()); //That's 1 EURO

4
	$this->account->deposit($money);
5
	$this->account->withdraw(new Money(70, Currency::EUR()));
6
7
	$this->assertEquals(new Money(30, Currency::EUR()), $this->account->getPrimaryBalance());
8
9
}

Esta es solo una prueba simple. La solución también es simple.

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

Bueno, eso funciona, pero ¿qué pasa si queremos usar una moneda que no está en tu cuenta? Deberíamos lanzar una excepción para eso.

1
/**

2
 * @expectedException Exception

3
 * @expectedExceptionMessage This account has no currency USD

4
 */
5
6
function testThrowsExceptionForInexistentCurrencyOnWithdraw() {
7
	$this->account->setPrimaryCurrency(Currency::EUR());
8
	$money = new Money(100, Currency::EUR()); //That's 1 EURO

9
	$this->account->deposit($money);
10
	$this->account->withdraw(new Money(70, Currency::USD()));
11
}

Eso también nos obligará a revisar nuestras monedas.

1
function withdraw(Money $money) {
2
	$this->validateCurrencyFor($money);
3
	$this->primaryCurrency == $money->getCurrency() ?
4
		$this->primaryBalance = $this->primaryBalance->subtract($money) :
5
		$this->secondaryBalance = $this->secondaryBalance->subtract($money);
6
}
7
8
private function validateCurrencyFor(Money $money) {
9
	if (!in_array($money->getCurrency(), $this->getCurrencies()))
10
			throw new Exception(
11
				sprintf(
12
					'This account has no currency %s',
13
					$money->getCurrency()->getStringRepresentation()
14
				)
15
			);
16
}

Pero, ¿y si queremos retirar más de lo que tenemos? Ese caso ya se abordó cuando implementamos la resta en Money. Aquí está la prueba que lo prueba.

1
/**

2
 * @expectedException Exception

3
 * @expectedExceptionMessage Subtracted money is more than what we have

4
 */
5
function testItThrowsExceptionIfWeTryToSubtractMoreMoneyThanWeHave() {
6
	$this->account->setPrimaryCurrency(Currency::EUR());
7
	$money = new Money(100, Currency::EUR()); //That's 1 EURO

8
	$this->account->deposit($money);
9
	$this->account->withdraw(new Money(150, Currency::EUR()));
10
}

Lidiar con el Retiro e Intercambio

Una de las cosas más difíciles de afrontar cuando trabajamos con varias divisas es el intercambio entre ellas. La belleza de este patrón de diseño es que nos permite simplificar un poco este problema aislándolo y encapsulándolo en su propia clase. Si bien la lógica en una clase de Exchange puede ser muy sofisticada, su uso se vuelve mucho más fácil. Por el bien de este tutorial, imaginemos que solo tenemos una lógica de Exchange muy básica. 1 EUR = 1,5 USD.

1
class Exchange {
2
3
	function convert(Money $money, Currency $toCurrency) {
4
		if ($toCurrency == Currency::EUR() && $money->getCurrency() == Currency::USD())
5
			return new Money($money->multiplyBy(0.67)->getAmount(), $toCurrency);
6
		if ($toCurrency == Currency::USD() && $money->getCurrency() == Currency::EUR())
7
			return new Money($money->multiplyBy(1.5)->getAmount(), $toCurrency);
8
		return $money;
9
	}
10
11
}

Si convertimos de EUR a USD multiplicamos el valor por 1,5, si convertimos de USD a EUR dividimos el valor por 1,5, de lo contrario suponemos que estamos convirtiendo dos divisas del mismo tipo, así que no hacemos nada y solo devolvemos el dinero. Por supuesto, en realidad esta sería una clase mucho más complicada.

Ahora, al tener una clase Exchange, la cuenta puede tomar diferentes decisiones cuando queremos retirar dinero en una moneda, pero no tenemos suficiente dinero en esa moneda específica. Aquí hay una prueba que lo ejemplifica mejor.

1
function testItConvertsMoneyFromTheOtherCurrencyWhenWeDoNotHaveEnoughInTheCurrentOne() {
2
	$this->account->setPrimaryCurrency(Currency::USD());
3
	$money = new Money(100, Currency::USD()); //That's 1 USD

4
	$this->account->deposit($money);
5
6
	$this->account->setSecondaryCurrency(Currency::EUR());
7
	$money = new Money(100, Currency::EUR()); //That's 1 EURO = 1.5 USD

8
	$this->account->deposit($money);
9
10
	$this->account->withdraw(new Money(200, Currency::USD())); //That's 2 USD

11
12
	$this->assertEquals(new Money(0, Currency::USD()), $this->account->getPrimaryBalance());
13
	$this->assertEquals(new Money(34, Currency::EUR()), $this->account->getSecondaryBalance());
14
}

Establecemos la moneda principal de nuestra cuenta en USD y depositamos un dólar. Luego, establecemos la moneda secundaria en EUR y depositamos un euro. Luego retiramos dos dólares. Finalmente, esperamos quedarnos con cero dólares y 0,34 euros. Por supuesto, esta prueba arroja una excepción, por lo que tenemos que implementar una solución a este dilema.

1
function withdraw(Money $money) {
2
	$this->validateCurrencyFor($money);
3
	if ($this->primaryCurrency == $money->getCurrency()) {
4
		if( $this->primaryBalance >= $money ) {
5
			$this->primaryBalance = $this->primaryBalance->subtract($money);
6
		}else{
7
			$ourMoney = $this->primaryBalance->add($this->secondaryToPrimary());
8
			$remainingMoney = $ourMoney->subtract($money);
9
			$this->primaryBalance = new Money(0, $this->primaryCurrency);
10
			$this->secondaryBalance = (new Exchange())->convert($remainingMoney, $this->secondaryCurrency);
11
		}
12
13
	} else {
14
		$this->secondaryBalance = $this->secondaryBalance->subtract($money);
15
	}
16
}
17
18
private function secondaryToPrimary() {
19
	return (new Exchange())->convert($this->secondaryBalance, $this->primaryCurrency);
20
}

Vaya, se tuvieron que hacer muchos cambios para admitir esta conversión automática. Lo que está sucediendo es que si estamos en el caso de extraer de nuestra moneda principal y no tenemos suficiente dinero, convertimos nuestro saldo de la moneda secundaria a primaria y volvemos a intentar la resta. Si aún no tenemos suficiente dinero, el objeto $ourMoney lanzará la excepción apropiada. De lo contrario, estableceremos nuestro saldo primario en cero y convertiremos el dinero restante a la moneda secundaria y estableceremos nuestro saldo secundario en ese valor.

Depende de la lógica de nuestra cuenta implementar una conversión automática similar para la moneda secundaria. No implementaremos una lógica tan simétrica. Si te gusta la idea, considérala como un ejercicio para ti. Además, piensa en un método privado más genérico que haría la magia de las conversiones automáticas en ambos casos.

Este complejo cambio en nuestra lógica también nos obliga a actualizar otra de nuestras pruebas. Siempre que queramos convertir automáticamente debemos tener un saldo, incluso si es solo cero.

1
/**

2
 * @expectedException Exception

3
 * @expectedExceptionMessage Subtracted money is more than what we have

4
 */
5
function testItThrowsExceptionIfWeTryToSubtractMoreMoneyThanWeHave() {
6
	$this->account->setPrimaryCurrency(Currency::EUR());
7
	$money = new Money(100, Currency::EUR()); //That's 1 EURO

8
	$this->account->deposit($money);
9
10
	$this->account->setSecondaryCurrency(Currency::USD());
11
	$money = new Money(0, Currency::USD());
12
	$this->account->deposit($money);
13
14
15
	$this->account->withdraw(new Money(150, Currency::EUR()));
16
}

Asignación de dinero entre cuentas

El último método que necesitamos implementar en Money es allocate. Esta es la lógica que decide qué hacer al dividir dinero entre diferentes cuentas que no se pueden hacer exactamente. Por ejemplo, si tenemos 0,10 centavos y queremos distribuirlos entre dos cuentas en una proporción de 30 a 70 por ciento, es fácil. Una cuenta obtendrá tres centavos y la otra siete. Sin embargo, si queremos hacer la misma asignación de razón de 30-70 de cinco centavos, tenemos un problema. La asignación exacta sería de 1,5 centavos en una cuenta y 3,5 en la otra. Pero no podemos dividir centavos, por lo que tenemos que implementar nuestro propio algoritmo para asignar el dinero.

Puede haber varias soluciones a este problema, un algoritmo común es agregar un centavo secuencialmente a cada cuenta. Si una cuenta tiene más centavos que su valor matemático exacto, debe eliminarse de la lista de asignación y no recibir más dinero. Aquí hay una representación gráfica.

AllocationsAllocationsAllocations

Y una prueba para demostrar nuestro punto está a continuación.

1
function testItCanAllocateMoneyBetween2Accounts() {
2
	$a1 = $this->anAccount();
3
	$a2 = $this->anAccount();
4
	$money = new Money(5, Currency::USD());
5
	$money->allocate($a1, $a2, 30, 70);
6
7
	$this->assertEquals(new Money(2, Currency::USD()), $a1->getPrimaryBalance());
8
	$this->assertEquals(new Money(3, Currency::USD()), $a2->getPrimaryBalance());
9
}
10
11
private function anAccount() {
12
	$account = new Account(1);
13
	$account->setPrimaryCurrency(Currency::USD());
14
	$account->deposit(new Money(0, Currency::USD()));
15
	return $account;
16
}

Simplemente creamos un objeto Money con cinco centavos y dos cuentas. Llamamos a allocate y esperamos que los dos o tres valores estén en las dos cuentas. También creamos un método auxiliar para crear cuentas rápidamente. La prueba falla, como se esperaba, pero podemos hacerla pasar con bastante facilidad.

1
function allocate(Account $a1, Account $a2, $a1Percent, $a2Percent) {
2
	$exactA1Balance = $this->amount * $a1Percent / 100;
3
	$exactA2Balance = $this->amount * $a2Percent / 100;
4
5
	$oneCent = new Money(1, $this->currency);
6
	while ($this->amount > 0) {
7
		if ($a1->getPrimaryBalance()->getAmount() < $exactA1Balance) {
8
			$a1->deposit($oneCent);
9
			$this->amount--;
10
		}
11
		if ($this->amount <= 0)
12
			break;
13
		if ($a2->getPrimaryBalance()->getAmount() < $exactA2Balance) {
14
			$a2->deposit($oneCent);
15
			$this->amount--;
16
		}
17
	}
18
}

Bueno, no es el código más simple, pero está funcionando correctamente, como lo demuestra la aprobación de nuestra prueba. Lo único que todavía podemos hacer con este código es reducir la pequeña duplicación dentro del ciclo while.

1
function allocate(Account $a1, Account $a2, $a1Percent, $a2Percent) {
2
	$exactA1Balance = $this->amount * $a1Percent / 100;
3
	$exactA2Balance = $this->amount * $a2Percent / 100;
4
5
	while ($this->amount > 0) {
6
		$this->allocateTo($a1, $exactA1Balance);
7
		if ($this->amount <= 0)
8
			break;
9
		$this->allocateTo($a2, $exactA2Balance);
10
	}
11
}
12
13
private function allocateTo($account, $exactBalance) {
14
	if ($account->getPrimaryBalance()->getAmount() < $exactBalance) {
15
		$account->deposit(new Money(1, $this->currency));
16
		$this->amount--;
17
	}
18
}

Reflexiones finales

Lo que encuentro asombroso con este pequeño patrón es la gran variedad de casos en los que podemos aplicarlo.

Hemos terminado con nuestro patrón de dinero. Vimos que es un patrón bastante simple, que encapsula los detalles del concepto de dinero. También vimos que esta encapsulación alivia la carga de los cálculos de "Account". La cuenta puede concentrarse en representar el concepto desde un nivel superior, desde el punto de vista del banco. La cuenta puede implementar métodos como la conexión con los titulares de la cuenta, identificaciones, transacciones y dinero. Será un orquestador, no una calculadora. El dinero se encargará de los cálculos.

Lo que encuentro asombroso con este pequeño patrón es la gran variedad de casos en los que podemos aplicarlo. Básicamente, cada vez que tengas un par de unidad de valor, puedes usarlo. Imagina que tienes una aplicación meteorológica y deseas implementar una representación para la temperatura. Ese sería el equivalente de nuestro objeto Money. Puedes usar Fahrenheit o Celsius en vez de las monedas.

Otro caso de uso es cuando tienes una aplicación de mapeo y deseas representar distancias entre puntos. Puedes utilizar fácilmente este patrón para cambiar entre medidas métricas o imperiales. Cuando trabajas con unidades simples, puedes soltar el objeto "Exchange" e implementar la lógica de conversión simple dentro de tu objeto "Money".

Por lo tanto, espero que hayas disfrutado de este tutorial y estoy ansioso por conocer las diferentes formas en que puedes usar este concepto. Gracias por leer.