
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 get0.954
, which, when rounded, becomes0.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 get0.955
, which becomes0.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 anenum
to simplify the example. But it could come from the database. Again, it would depend on the context of our application.
<?phpdeclare(strict_types=1);namespacerubenrubiob\Domain\ValueObject;enumVATPercentage:int{caseES_IVA_21=21;}
Amount
We could have a value object to represent the unitary price:
<?phpdeclare(strict_types=1);namespacerubenrubiob\Domain\ValueObject;useBrick\Math\BigDecimal;useBrick\Math\Exception\NumberFormatException;useBrick\Math\RoundingMode;useBrick\Money\Exception\UnknownCurrencyException;useBrick\Money\Money;userubenrubiob\Domain\Exception\ValueObject\AmountIsNotValid;usefunctionstrtoupper;finalreadonlyclassAmount{privatefunction__construct(privateint$basePrice,privateVATPercentage$vatPercentage,privateint$vatAmount,privateint$finalPrice,privatestring$currency,){}/** * @throws AmountIsNotValid */publicstaticfunctionfromFinalPriceAndVATPercentage(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);returnnewself($basePrice->getMinorAmount()->toInt(),$vatPercentage,$vatAmount->getMinorAmount()->toInt(),$finalPriceAsMoney->getMinorAmount()->toInt(),$finalPriceAsMoney->getCurrency()->getCurrencyCode(),);}/** @throws AmountIsNotValid */privatestaticfunctionparseAndGetMoney(float|int|string$amount,string$currency):Money{try{returnMoney::of($amount,strtoupper($currency),null,RoundingMode::HALF_UP);}catch(NumberFormatException|UnknownCurrencyException){throwAmountIsNotValid::ambPreuFinal($amount,$currency);}}privatestaticfunctiongetVATAmount(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 thisAmount
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 theMoney
object frombrick/money
. In the event of an error, we throw a domain exception.
Frombrick/math
, we useBigDecimal
to perform the VAT calculation. When performing a division, there could be a loss of decimal numbers. We can not useMoney
in this case, as it always rounds to the number of decimal places of the currency.
We can write the test forAmount
as following:
<?phpdeclare(strict_types=1);privateconstCURRENCY_UPPER='EUR';privateconstCURRENCY_LOWER='eur';#[DataProvider('basePriceAndVATProvider')]publicfunctiontest_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());}publicstaticfunctionbasePriceAndVATProvider():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:
<?phpdeclare(strict_types=1);namespacerubenrubiob\Domain\Model;useBrick\Money\Currency;useBrick\Money\Exception\UnknownCurrencyException;useBrick\Money\ISOCurrencyProvider;useBrick\Money\Money;userubenrubiob\Domain\Exception\Model\AmountListCurrencyDoesNotMatch;userubenrubiob\Domain\Exception\Model\AmountListCurrencyIsNotValid;userubenrubiob\Domain\ValueObject\Amount;usefunctionstrtoupper;finalclassAmountList{/** @var list<Amount> */privatearray$amounts=[];privateMoney$totalBasePrices;privateMoney$totalVat;privateMoney$total;privatefunction__construct(privatereadonlyCurrency$currency,){$this->totalBasePrices=Money::zero($this->currency);$this->totalVat=Money::zero($this->currency);$this->total=Money::zero($this->currency);}/** @throws AmountListCurrencyIsNotValid */publicstaticfunctionwithCurrency(string$moneda):self{returnnewself(self::parseAndValidateCurrency($moneda),);}/** @throws AmountListCurrencyDoesNotMatch */publicfunctionaddAmount(Amount$import):void{$this->assertCurrencyMatch($import);$this->recalculateTotals($import);$this->amounts[]=$import;}/** @throws AmountListCurrencyIsNotValid */privatestaticfunctionparseAndValidateCurrency(string$moneda):Currency{try{returnISOCurrencyProvider::getInstance()->getCurrency(strtoupper($moneda));}catch(UnknownCurrencyException){throwAmountListCurrencyIsNotValid::create($moneda);}}/** @throws AmountListCurrencyDoesNotMatch */privatefunctionassertCurrencyMatch(Amount$amount):void{if($amount->currency()!==$this->currency->getCurrencyCode()){throwAmountListCurrencyDoesNotMatch::forListAndCurrency($this->currency->getCurrencyCode(),$amount->currency(),);}}privatefunctionrecalculateTotals(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:
<?phpprivateconstCURRENCY_LOWER='eur';publicfunctiontest_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 theAmount
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 anAmount
. 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 anembeddable as follows:
<?xml version="1.0" encoding="UTF-8" ?><doctrine-mappingxmlns="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"><embeddablename="rubenrubiob\Domain\ValueObject\Amount"><fieldname="basePrice"type="integer"column="base_price"/><fieldname="VATPercentage"type="integer"enumType="rubenrubiob\Domain\ValueObject\VATPercentage"column="vat_percentage"/><fieldname="vatAmount"type="integer"column="vat_amount"/><fieldname="finalPrice"type="integer"column="final_price"/><fieldname="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 anAmountList
, 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(1)

I’ve done that swap before—USDC on Polygon to TRX—and it went way easier than I expected. I didn’t have to be a techie to figure it outpaybis.com/swap-usdc-polygon-to-trx/. The platform I used handled the conversion in a few simple steps, and I got my TRX pretty much instantly after sending USDC. No unnecessary KYC delays either. Honestly, it felt like swapping crypto in 2025 should—quick, global, and secure. Just double-check that your wallet supports both tokens before you go for it.
For further actions, you may consider blocking this person and/orreporting abuse