Introduction
In the previous post, we reviewed the problems that arise when working with monetary values due to the problem of representing floating-point numbers. A solution could be to use the money pattern, which stores the amounts at the minimum value of the currency.
In this post, we will see an implementation to solve the example we showed in the previous article.
Implementation
In the case we saw, the software set the final price and expected to get the following values:
Base price (€) | VAT (21%) (€) | Final price (€) |
---|---|---|
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.65 | 9.35 | 54.00 |
The €5.50 is an interesting case because it shows the ambiguity we face.
To calculate the VAT amount from the final price, we perform the following operation: VAT = FinalPrice - FinalPrice/1.21
. In this case, we get 0.954
, which, when rounded, becomes 0.95
.
However, if we calculate the VAT from the base price, we calculate it the following way: VAT = NetPrice * 21/100
. In this case, we get 0.955
, which becomes 0.96
when rounded.
Therefore, depending on the value we use to calculate the VAT amount, we will get different prices. For instance, in the case we will review, the client wanted a final price of €5.50, so we will calculate the final price from the base price. This depends on each case, on how the domain is defined, and on the data we have. There is no silver bullet. In financial affairs, the best advice is given by experts.
Next, we will see an implementation to solve this example.
VATPercentage
To perform the calculations, we need the VAT percentage first. In this case, we extracted it to an enum
to simplify the example. But it could come from the database. Again, it would depend on the context of our application.
<?php
declare(strict_types=1);
namespace rubenrubiob\Domain\ValueObject;
enum VATPercentage: int
{
case ES_IVA_21 = 21;
}
Amount
We could have a value object to represent the unitary price:
<?php
declare(strict_types=1);
namespace rubenrubiob\Domain\ValueObject;
use Brick\Math\BigDecimal;
use Brick\Math\Exception\NumberFormatException;
use Brick\Math\RoundingMode;
use Brick\Money\Exception\UnknownCurrencyException;
use Brick\Money\Money;
use rubenrubiob\Domain\Exception\ValueObject\AmountIsNotValid;
use function strtoupper;
final readonly class Amount
{
private function __construct(
private int $basePrice,
private VATPercentage $vatPercentage,
private int $vatAmount,
private int $finalPrice,
private string $currency,
) {
}
/**
* @throws AmountIsNotValid
*/
public static function fromFinalPriceAndVATPercentage(
float|int|string $finalPrice,
VATPercentage $vatPercentage,
string $currency,
): self {
$finalPriceAsMoney = self::parseAndGetMoney($finalPrice, $currency);
$basePrice = $finalPriceAsMoney->dividedBy(
self::getVATAmount($vatPercentage),
RoundingMode::HALF_UP,
);
$vatAmount = $finalPriceAsMoney->minus($basePrice);
return new self(
$basePrice->getMinorAmount()->toInt(),
$vatPercentage,
$vatAmount->getMinorAmount()->toInt(),
$finalPriceAsMoney->getMinorAmount()->toInt(),
$finalPriceAsMoney->getCurrency()->getCurrencyCode(),
);
}
/** @throws AmountIsNotValid */
private static function parseAndGetMoney(float|int|string $amount, string $currency): Money
{
try {
return Money::of($amount, strtoupper($currency), null, RoundingMode::HALF_UP);
} catch (NumberFormatException | UnknownCurrencyException) {
throw AmountIsNotValid::ambPreuFinal($amount, $currency);
}
}
private static function getVATAmount(VATPercentage $percentatgeImpostos): BigDecimal
{
$vatPercentageAsBigDecimal = BigDecimal::of($percentatgeImpostos->value);
return $vatPercentageAsBigDecimal
->dividedBy(100, RoundingMode::HALF_UP)
->plus(1);
}
}
We have the five components of a unitary price in this value object:
- The base price in its minor unit
- The VAT percentage applied to the base price
- The VAT amount in its minor unit
- The final price in its minor unit
- The currency.
We build this Amount
using the named constructor from the final price because that is what the domain defines.
For validating both the value and the currency, we use the Money
object from brick/money
. In the event of an error, we throw a domain exception.
From brick/math
, we use BigDecimal
to perform the VAT calculation. When performing a division, there could be a loss of decimal numbers. We can not use Money
in this case, as it always rounds to the number of decimal places of the currency.
We can write the test for Amount
as following:
<?php
declare(strict_types=1);
private const CURRENCY_UPPER = 'EUR';
private const CURRENCY_LOWER = 'eur';
#[DataProvider('basePriceAndVATProvider')]
public function test_with_final_price_and_vat_returns_expected_minor_values(
int $expectedMinorBasePrice,
int $expectedMinorVatAmount,
int $expectedMinorFinalPrice,
float|string $finalPrice,
): void {
$amount = Amount::fromFinalPriceAndVATPercentage(
$finalPrice,
VATPercentage::ES_IVA_21,
self::CURRENCY_LOWER,
);
self::assertSame($expectedMinorBasePrice, $amount->basePrice());
self::assertSame($expectedMinorVatAmount, $amount->vatAmountMinor());
self::assertSame($expectedMinorFinalPrice, $amount->finalPriceMinor());
self::assertSame(21, $amount->vatPercentage()->value);
self::assertSame(self::CURRENCY_UPPER, $amount->currency());
}
public static function basePriceAndVATProvider(): array
{
return [
'5.50 (float)' => [
455,
95,
550,
5.50,
],
'5.30 (float)' => [
438,
92,
530,
5.30,
],
];
}
- To ensure the calculations are correct, we test the problematic examples we have.
- We assert the values using the minor unit of the monetary amounts. We could have also used Money.
- We use a data provider that could be extended to cover more cases, building the object from a string or float, for example.
AmountList
To represent a list of amounts (what could be a bill), we have the following implementation:
<?php
declare(strict_types=1);
namespace rubenrubiob\Domain\Model;
use Brick\Money\Currency;
use Brick\Money\Exception\UnknownCurrencyException;
use Brick\Money\ISOCurrencyProvider;
use Brick\Money\Money;
use rubenrubiob\Domain\Exception\Model\AmountListCurrencyDoesNotMatch;
use rubenrubiob\Domain\Exception\Model\AmountListCurrencyIsNotValid;
use rubenrubiob\Domain\ValueObject\Amount;
use function strtoupper;
final class AmountList
{
/** @var list<Amount> */
private array $amounts = [];
private Money $totalBasePrices;
private Money $totalVat;
private Money $total;
private function __construct(
private readonly Currency $currency,
) {
$this->totalBasePrices = Money::zero($this->currency);
$this->totalVat = Money::zero($this->currency);
$this->total = Money::zero($this->currency);
}
/** @throws AmountListCurrencyIsNotValid */
public static function withCurrency(string $moneda): self
{
return new self(
self::parseAndValidateCurrency($moneda),
);
}
/** @throws AmountListCurrencyDoesNotMatch */
public function addAmount(Amount $import): void
{
$this->assertCurrencyMatch($import);
$this->recalculateTotals($import);
$this->amounts[] = $import;
}
/** @throws AmountListCurrencyIsNotValid */
private static function parseAndValidateCurrency(string $moneda): Currency
{
try {
return ISOCurrencyProvider::getInstance()->getCurrency(strtoupper($moneda));
} catch (UnknownCurrencyException) {
throw AmountListCurrencyIsNotValid::create($moneda);
}
}
/** @throws AmountListCurrencyDoesNotMatch */
private function assertCurrencyMatch(Amount $amount): void
{
if ($amount->currency() !== $this->currency->getCurrencyCode()) {
throw AmountListCurrencyDoesNotMatch::forListAndCurrency(
$this->currency->getCurrencyCode(),
$amount->currency(),
);
}
}
private function recalculateTotals(Amount $import): void
{
$this->totalBasePrices = $this->totalBasePrices->plus(
$import->basePriceAsMoney(),
);
$this->totalVat = $this->totalVat->plus(
$import->vatAmountAsMoney(),
);
$this->total = $this->total->plus(
$import->finalPriceAsMoney(),
);
}
}
- We have a method to initialize the list.
- All the components will be a
Money
initialized to 0: the sum of base prices, the sum of VAT and the total amount. - We add the
Amount
one by one:- First, we validate the currencies match.
- We sum each amount using
Money
.
As we have a real example, we can write a test to replicate this case and validate that the implementation is correct:
<?php
private const CURRENCY_LOWER = 'eur';
public function test_with_valid_amounts_return_expected_values(): void
{
$firstAmount = Amount::fromFinalPriceAndVATPercentage(
5.50,
VATPercentage::ES_IVA_21,
self::CURRENCY_LOWER,
);
$secondAmount = Amount::fromFinalPriceAndVATPercentage(
5.30,
VATPercentage::ES_IVA_21,
self::CURRENCY_LOWER,
);
$amountList = AmountList::withCurrency(self::CURRENCY_LOWER);
$amountList->addAmount($firstAmount);
$amountList->addAmount($firstAmount);
$amountList->addAmount($firstAmount);
$amountList->addAmount($firstAmount);
$amountList->addAmount($firstAmount);
$amountList->addAmount($secondAmount);
$amountList->addAmount($secondAmount);
$amountList->addAmount($secondAmount);
$amountList->addAmount($secondAmount);
$amountList->addAmount($secondAmount);
self::assertSame(4465, $amountList->totalBasePricesMinor());
self::assertSame(935, $amountList->totalVatMinor());
self::assertSame(5400, $amountList->totalMinor());
self::assertCount(10, $amountList->amounts());
}
Persistence
As currencies have an official standard, the ISO-4127, we can persist all components of the Amount
value object as scalar values, and then reconstruct the value object from them.
Depending on the database engine we use, we have different strategies to persist an Amount
. In a No-SQL database, we could persist the components using JSON. This is also a valid option for a SQL database. In this case, however, it is possible to persist each component in its own column. Using Doctrine as an ORM, we can use an embeddable as follows:
<?xml version="1.0" encoding="UTF-8" ?>
<doctrine-mapping xmlns="https://doctrine-project.org/schemas/orm/doctrine-mapping"
xmlns:xsi="https://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="https://doctrine-project.org/schemas/orm/doctrine-mapping
https://www.doctrine-project.org/schemas/orm/doctrine-mapping.xsd">
<embeddable name="rubenrubiob\Domain\ValueObject\Amount">
<field name="basePrice" type="integer" column="base_price" />
<field name="VATPercentage" type="integer" enumType="rubenrubiob\Domain\ValueObject\VATPercentage" column="vat_percentage" />
<field name="vatAmount" type="integer" column="vat_amount" />
<field name="finalPrice" type="integer" column="final_price" />
<field name="currency" type="string" column="currency" length="3" />
</embeddable>
</doctrine-mapping>
Conclusion
Following the problem we described in the previous post, we saw the calculations we must perform in order to obtain the base price and the VAT amount from the final price, as the domain defined.
We implemented a solution using a value object, Amount
, that contains all the components of a price, and an AmountList
, that could represent a bill. We are sure that the implementation is valid thanks to the unit tests we wrote to replicate the example.
Lastly, we explained how to persist an amount and saw an example of an embeddable when using Doctrine as an ORM.
Top comments (0)