Movatterモバイル変換


[0]ホーム

URL:


Skip to content
DEV Community
Log in Create account

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.550.955.50
4.550.955.50
4.550.955.50
4.550.955.50
4.550.955.50
4.380.925.30
4.380.925.30
4.380.925.30
4.380.925.30
4.380.925.30
44.659.3554.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;}
Enter fullscreen modeExit fullscreen mode

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);}}
Enter fullscreen modeExit 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 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,],];}
Enter fullscreen modeExit 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:

<?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(),);}}
Enter fullscreen modeExit fullscreen mode
  • We have a method to initialize the list.
  • All the components will be aMoney initialized to 0: the sum of base prices, the sum of VAT and the total amount.
  • We add theAmount one by one:
    • First, we validate the currencies match.
    • We sum each amount usingMoney.

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

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>
Enter fullscreen modeExit 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 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)

Subscribe
pic
Create template

Templates let you quickly answer FAQs or store snippets for re-use.

Dismiss
CollapseExpand
 
william_thompson_2e580c24 profile image
William Thompson
  • Joined

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.

Are you sure you want to hide this comment? It will become hidden in your post, but will still be visible via the comment'spermalink.

For further actions, you may consider blocking this person and/orreporting abuse

  • Work
    Senior backend consultant (PHP)
  • Joined

More fromRubén Rubio

DEV Community

We're a place where coders share, stay up-to-date and grow their careers.

Log in Create account

[8]ページ先頭

©2009-2025 Movatter.jp