Calculate with Prices
As floating point numbers (float
, double
) are not able to represent numbers exactly (see here
if you want to know details), and exact numbers are a strict demand to e-commerce applications the E-Commerce Framrwork
uses Decimal
value objects instead of floats to represent prices. These value objects internally store the represented value as integer
by defining a fixed amount of supported digits (so-called scale
) after the comma and by multiplying the actual value
with the given scale on construction. The scale is set to 4 by default, but can be changed globally in the pimcore_ecommerce_framework.decimal_scale
config entry.
An example: Given a scale of 4, a Decimal
will internally represent a number of 123.45
as 1234500
by calculating
123.45 * 10^4 = 1234500
.
To calculate with these values, the Decimal
class exposes methods like add()
, sub()
, mul()
, div()
and others
to run calculations without having to deal with the internal scale representation. For details see the Decimal class definition
and the corresponding test
which contains a lot of usage examples and describes the Decimal
behaviour quite well.
Important: The
Decimal
is designed as immutable value object. Every operation yields a new instance of aDecimal
representing the new value. Please be aware that this needs to be considered when calculating withDecimal
s and/or when extending theDecimal
class with custom functionality.
On the DB side, all E-Commerce Framework class definitions supporting price values were updated to store prices as DECIMAL
data type. This means, when fetching a price value from an ecommerce-object (e.g. an order), it will be a string instead
of a float
. This string can directly be passed to the Decimal
value object's create()
method.
A usage example:
<?php
use Pimcore\Bundle\EcommerceFrameworkBundle\Type\Decimal;
$a = Decimal::create(10);
$b = Decimal::create(20);
$c = $a->add($b);
var_dump($c->asString()); // 10.0000 as the default string representation contains all digits depending on the scale
// regarding immutability:
$a->add($b);
// $a is still 10 and will never be changed - the add() method returns a new object ($c in the example above)
$a->equals(Decimal::create(30)); // false
$a->equals(Decimal::create(10)); // true
In most cases, you should be able to build a Decimal
object by using the static create()
method which is able to build
a Decimal
from strings, integers and floating point numbers. If needed, you can also make use of a number of from*
methods
directly exposing costruction logic for a specific type (e.g. fromDecimal(Decimal $decimal)
).
Rounding logic
The Decimal
object will try to avoid rounding and calculating with floats if possible. If you pass a string to the create()
method, it will try to convert the numeric string to an integer with string operations before falling back to converting
it into a float which is rounded.
Examples (given a scale of 4):
-
Decimal::create('123')
will just add 4 chars of0
to the string and cast it to an integer (no floats involved) - results in1230000
-
Decimal::create('123.45')
will move the dot to the right by 2 places and add 2 chars of0
- results in1234500
However, if the given value exceeds the maximum scale or is a float value, the create()
method needs to fall back to
float calculations and rounding to generate the integer representation:
-
Decimal:create('123.1234567')
with a string value will fall back to float calculations as the amount of digits after the comma exceed the scale of4
. The value is first multiplied with10^4
, resulting in PHP casting the string to float. The float1231234.567
is then passed to PHP'sround()
function with aprecision
parameter of0
and a default rounding mode ofPHP_ROUND_HALF_EVEN
, resulting in an integer representation of1231235
(note the rounding on the last digit). -
Decimal:create(123.1234567)
with a float value will have the same behaviour as above (skipping the string conversion).
You can influence how rounding is applied by specifying the $roundingMode
parameter on the create()
method:
<?php
use Pimcore\Bundle\EcommerceFrameworkBundle\Type\Decimal;
var_dump(Decimal::create('123.55555', 4, PHP_ROUND_HALF_DOWN)->asString()); // 123.5555
var_dump(Decimal::create('123.55555', 4, PHP_ROUND_HALF_UP)->asString()); // 123.5556
Please be aware that as rounding is applied only when exceeding the scale, the following can happen:
<?php
use Pimcore\Bundle\EcommerceFrameworkBundle\Type\Decimal;
// supported by scale -> 1.9999
var_dump(Decimal::create('1.9999')->asString());
// rounding is applied -> 2.0000
var_dump(Decimal::create('1.99999')->asString());
This is important to know as it could lead to unexpected calculation results. The DECIMAL
mysql data type has the same
behaviour - if you calculate with Decimal
s at a higher scale you still need to update your class definitions to match
that scale as otherwise the same rounding logic will be applied by the database. However, while doing calculations (e.g.
tax or discount calculations, reports), you're free to calculate at a higher scale by passing the scale
parameter to the
create()
method and just setting the resulting value to the scale represented on the DB. Example:
<?php
use Pimcore\Bundle\EcommerceFrameworkBundle\Type\Decimal;
// operate at a high scale supporting 8 digits after the comma
$a = Decimal::create(10, 8);
// do calculations
$b = $a->toPercentage(15);
// create a representation with a lower scale - at this point rounding logic will be applied
$result = $b->withScale(4);
// an order object
$order->setTotalPrice($result->asString());
$order->save();
Extending the Decimal class
You're free to add custom calculation logic to the Decimal class, but please make sure every operation returns a new instance holding the calculated value. An object can never change its internal state (value and scale) after construction!
Caveats/aspects good to know
- In order to have sufficient number of digits in integer datatype, your system should run on 64 bit infrastructure.
On 32 bit systems, you would be able to handle prices up to 214,748.3647 only (since max int value with 32 bit is 2,147,483,647).
The
Decimal
implementation contains logic to throw an exception on an integer overflow in case the used numbers are too large/small.