Introduction
As a business requirement, we needed to implement a new payment method. All our payments methods are in legacy code, so we took the opportunity to implement a way to manage amounts, trying to avoid problems we found in the past.
Besides an amount, a money always has a currency. When we say a product costs 7.99, what are we referring to? €7.99? $7.99? ¥7.99? The price of a product can vary a lot if we do not take into account the different currencies.
For small businesses that only operate in a country or a region with a single currency, it may have sense to always assume the use of this currency everywhere. If the application does not expand to a region with a different currency, this assumption will be safe. But we can never be a hundred percent sure that this will not happen in the future.
To solve this problem, we can use a pattern know as "Money pattern", that consists on having a Value Object that has two attributes: the amount and the currency.
Precision
Representing numbers in software is always a complex task. As numbers usually have decimal parts, our first intuition to represent an amount is to use floating point numbers. But floating point numbers have a limited amount of decimals they are able to represent, as the machine's memory is limited. For example, the number 2/3 is 0.666... At some point, the system will round the value, for example, to 0.666667. Therefore, there will always be some loss of precision when using floating point numbers.
We can try to solve this by rounding the numbers at some point during the execution.
PHP Functions
In PHP, we have multiple functions that allow to round numbers. These are some of them:
floor
floor($amount);
Returns the next lowest integer value (as float) by rounding down value if necessary
ceil
ceil($value);
Returns the next highest integer value by rounding up the value if necessary.
round
round($amount, $precision, $mode);
Returns the rounded value of val to specified precision (number of digits after the decimal point). Precision can also be negative or zero (default).
number_format
number_format($amount, $decimals);
Formats a number with grouped thousands and optionally decimal digits.
Example
Let's see a real example to see how this works.
I worked in an e-commerce project that had a back-office where shop administrators could set the base price (without taxes) for their products. The VAT amount (of 21%) and the final price were automatically calculated from that base price.
This concrete administrator wanted to have two different products, with a final price (including taxes) of €5.50 and €5.30. We had a user that bought 5 units of each product. So, ideally, the total price for that order would be €54.00:
Base price (€) | VAT 21% (€) | Total (€) |
---|---|---|
4.55 | 0.95 | 5.50 |
4.55 | 0.95 | 5.50 |
4.55 | 0.95 | 5.50 |
4.55 | 0.95 | 5.50 |
4.55 | 0.95 | 5.50 |
4.38 | 0.92 | 5.30 |
4.38 | 0.92 | 5.30 |
4.38 | 0.92 | 5.30 |
4.38 | 0.92 | 5.30 |
4.38 | 0.92 | 5.30 |
44.63 | 9.37 | 54.00 |
However, things were not that nice. The back-office form allowed the administrator to introduce three decimal point numbers in order to (unsuccessfully) avoid rounding problems. The values were rounded before saving. Therefore, the value that was sent to the bank to charge the client for was as follows:
Base price (€) | VAT 21% (€) | Total (€) |
---|---|---|
4.545 | 0.9545 | 5.4995 |
4.545 | 0.9545 | 5.4995 |
4.545 | 0.9545 | 5.4995 |
4.545 | 0.9545 | 5.4995 |
4.545 | 0.9545 | 5.4995 |
4.380 | 0.9198 | 5.2998 |
4.380 | 0.9198 | 5.2998 |
4.380 | 0.9198 | 5.2998 |
4.380 | 0.9198 | 5.2998 |
4.380 | 0.9198 | 5.2998 |
44.625 | 9.371 | 53.996 |
As you see, the client paid 1 cent less than she should have. It is not much, but in a store with thousands of transactions there would be a sensible loss. Fortunately (or not), it was not the case.
But things did not end there. Instead of doing a single calculation for the whole order and save the values in the database, the application was only saving the basic values (the unitary amount and the quantity for each line) and performing the calculations in each place they were needed. The problem here is that the calculations were done differently in each place, i.e., the application was rounding values earlier or later in the total calculation. So, even if the amount the client paid was €53.996, in the order confirmation email the application sent him, the amounts were calculated as following:
Base price (€) | VAT 21% (€) | Total (€) |
---|---|---|
4.550 | 0.9555 | 5.5055 |
4.550 | 0.9555 | 5.5055 |
4.550 | 0.9555 | 5.5055 |
4.550 | 0.9555 | 5.5055 |
4.550 | 0.9555 | 5.5055 |
4.380 | 0.9198 | 5.2998 |
4.380 | 0.9198 | 5.2998 |
4.380 | 0.9198 | 5.2998 |
4.380 | 0.9198 | 5.2998 |
4.380 | 0.9198 | 5.2998 |
44.65 | 9.38 | 54.03 |
So, the administrator expected to charge a total amount of €54.00, the client paid €53.996 whilst the total amount in the confirmation email showed €54.03.
Therefore, rounding values using PHP functions is not a suitable way of working with amounts (neither for numbers).
Libraries
Fortunately, in PHP there are open source libraries that solve these two problems, namely, working with potentially infinite numbers (up to a limit) and having an amount with a currency.
The ones we checked in depth were the following:
We finally chose brick/money
because it uses underlying the package brick/math
, that allows more robust calculations. There is a comparison between the two libraries, but you should be safe using either one of them, specially after moneyphp/money
got a big rewrite one month ago (on May 2021).
Implementation
We implemented an Amount
Value Object:
<?php
declare(strict_types = 1);
namespace SharedKernel\Domain\ValueObject;
use Brick\Math\BigNumber;
use Brick\Math\Exception\NumberFormatException;
use Brick\Math\RoundingMode;
use Brick\Money\Exception\MoneyMismatchException;
use Brick\Money\Exception\UnknownCurrencyException;
use Brick\Money\Money;
use SharedKernel\Domain\Exception\InvalidAmountException;
final class Amount
{
private const ROUNDING_MODE = RoundingMode::HALF_UP;
private int $amount;
private string $currency;
private function __construct(int $amount, string $currency)
{
$this->amount = $amount;
$this->currency = $currency;
}
/**
* @param BigNumber|float|int|string $amount
* @param string $currency
*
* @return Amount
*
* @throws InvalidAmountException
*/
public static function of($amount, string $currency): self
{
$amountAsMoney = self::parseAndValidateOrFail($amount, $currency);
return new self(
$amountAsMoney->getMinorAmount()->toInt(),
$amountAsMoney->getCurrency()->getCurrencyCode(),
);
}
public function equalsTo(self $secondAmount): bool
{
try {
return $this->amount()->isEqualTo($secondAmount->amount());
} catch (MoneyMismatchException $e) {
return false;
}
}
public function amount(): Money
{
return Money::ofMinor($this->amount, $this->currency, null, self::ROUNDING_MODE);
}
/**
* @throws InvalidAmountException
*/
private static function parseAndValidateOrFail($amount, string $currency): Money
{
try {
return Money::of($amount, strtoupper($currency), null, self::ROUNDING_MODE);
} catch (UnknownCurrencyException $e) {
throw InvalidAmountException::withInvalidCurrency($currency);
} catch (NumberFormatException $e) {
throw InvalidAmountException::withInvalidAmountFormat($amount);
}
}
}
The amount has two scalar attributes:
-
amount
: stored asint
with the minor unit (cents). For example, €105.35 will be stored as 10535. -
currency
: the currency code defined by the ISO 4127 standard.
A Value Object, by definition, can not be constructed invalid. In our case, we take advantage of the brick/money
library to validate any input value, and then we represent them with both amount
and currency
attributes. We can generate a Brick\Money\Money
value from them.
As moneys have an official scale for the currency defined by the ISO 4127 standard. Therefore, the idea is to persist
both components as scalar values, because we can then generate a Brick\Money\Money
value from them.
We can also encapsulate any required operations in SharedKernel\Domain\ValueObject\Amount
.
Persistence
As moneys have an official scale for the currency defined by the ISO 4127 standard, the idea is to persist
both components, amount
and currency
as scalar values, and then reconstruct the Amount
ValueObject from them.
There are different options to persist the amount depending on the persistence layer we use. We have a concrete case where we use Postgres with Doctrine ORM as an abstraction layer, so we chose to persist both attributes as different columns using an embeddable:
<?xml version="1.0" encoding="UTF-8" ?>
<doctrine-mapping xmlns="http://doctrine-project.org/schemas/orm/doctrine-mapping"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://doctrine-project.org/schemas/orm/doctrine-mapping
http://doctrine-project.org/schemas/orm/doctrine-mapping.xsd">
<embeddable name="SharedKernel\Domain\ValueObject\Amount">
<field name="amount" type="integer" column="amount" />
<field name="currency" type="string" column="currency" length="3" />
</embeddable>
</doctrine-mapping>
This way, we can map the Amount
Value Object to our entities easily. Another option would have been to create a custom type to persist both values as JSON in a single column. That would be the chosen option if we used a No-SQL storage.
Summary
On one side, we saw that managing numbers in computing is complex and prone to errors. We also saw that a money always has to have a currency, and that the money pattern precisely allows to do that.
On the other side, we saw that it is better to not reinvent the wheel and implement a custom solution, but to use libraries that are specifically designed for the task.
Finally, we saw an implementation of the money pattern in PHP as a Value Object, with a possible persistence strategy using Doctrine.
Top comments (2)
How about save amount as decimal for comply with GAAP ?
I am not familiarized with GAAP, but I guess you could always have an extra column to save the amount in the format you require. You can take advantage of the Money pattern, as it allows you to convert the values to the format you need.