DEV Community

loading...
Filmin Engineering

Money pattern in PHP

Rubén Rubio
・7 min read

Shut up and take my money

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

Returns the next lowest integer value (as float) by rounding down value if necessary

ceil

ceil($value);
Enter fullscreen mode Exit fullscreen mode

Returns the next highest integer value by rounding up the value if necessary.

round

round($amount, $precision, $mode);
Enter fullscreen mode Exit fullscreen 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);
Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

The amount has two scalar attributes:

  • amount: stored as int 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>
Enter fullscreen mode Exit fullscreen mode

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.

Discussion (0)