DEV Community

Cover image for Money pattern in PHP: the solution
Rubén Rubio
Rubén Rubio

Posted on

Money pattern in PHP: the solution

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;
}

Enter fullscreen mode Exit fullscreen mode

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);
    }
}

Enter fullscreen mode Exit fullscreen mode

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,
        ],
    ];
}

Enter fullscreen mode Exit fullscreen mode
  • 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(),
        );
    }
}

Enter fullscreen mode Exit fullscreen mode
  • 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());
}
Enter fullscreen mode Exit fullscreen mode

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>

Enter fullscreen mode Exit fullscreen mode

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)