cult3

Creating a PHP Shopping Basket Part 6 - Meta data and Processing

Feb 25, 2015

Table of contents:

  1. Products, the Basket and the Reconciliation Process
  2. Calculating Meta Data
  3. Processing an Order
  4. The Processor object
  5. Conclusion

A common requirement in ecommerce applications is the ability to provide meta data about an order.

For example, you will typically need to calculate the total value, discounts and delivery charges for an order so you can record that information in your database or send those details to your payment gateway.

However every ecommerce application will typically have a slightly different set of requirements for what data they need to record.

Smaller applications will have fewer requirements, whilst bigger applications will likely need a wealth of data about each individual order.

As we’ve seen a couple of times in this mini-series, it’s usually a good idea to not restrict this type of functionality, but instead leave the door open for extension.

Ideally we should give the developer the option to include as few or as many meta data calculations as she chooses.

We should also make it very easy for a developer to add her own meta data calculations so she can satisfy the requirements of her individual project.

In today’s tutorial we are going to be looking at processing and calculating the meta data of orders.

Products, the Basket and the Reconciliation Process

Before we jump into implementing the order processing and meta data calculating, first a quick recap of how we got to this point.

The Product object holds state about each product in the basket. This includes data on the quantity, price, discounts or anything else that is important about the product. You can read more about the Product object at Creating a PHP Shopping Basket Part 3 - Creating the Product object.

The Basket object is a container for the product collection and is the main interface to the package. The Basket manages adding, updating and removing products. You can read more about the Basket object at Creating a PHP Shopping Basket Part 4 - The Basket object.

The Reconciler is a stateless service for calculating the various totals of a given Product object because the Product should not be responsible for calculating its own totals. We defined the Reconciler as an interface so a developer who is using this package can write her own implementation to satisfy the requirements of her application. You can read more about the reconciliation process at Creating a PHP Shopping Basket Part 5 - The Reconciliation Process.

Calculating Meta Data

When a customer moves to the checkout within the ecommerce application, we need a way to calculate the various meta data items of the order. For example, we might need the total value of the order, the total delivery and a breakdown of the tax.

Due to the fragmented and varying requirements of ecommerce applications, we need to define a generic method for calculating meta data about an order.

This means we can create some default implementations for common requirements such as total value, total delivery and total tax.

But also, any developer will be free to implement her own meta data calculations too.

As we’ve seen a couple of times in this mini-series, in order to open up our code for extension, we need to define an interface:

<?php namespace PhilipBrown\Basket;

interface MetaData
{
    /**
     * Generate the Meta Data
     *
     * @param Basket $basket
     * @return mixed
     */
    public function generate(Basket $basket);

    /**
     * Return the name of the Meta Data
     *
     * @return string
     */
    public function name();
}

By defining an interface we allow any developer to write their own implementation as long as it satisfies the contract of the interface.

To read more about PHP interfaces, take a look at When should I code to an Interface?.

The MetaData interface requires two methods to be implemented.

Firstly, we have the generate() method that accepts an instance of Basket. This method is where the developer will write the logic to actually calculate the meta data she requires.

Secondly, we have the name() method. This method will return a string of the name of the meta data item as it will appear within the processed order.

With the interface in place, now we can define the default implementations that will ship with this package.

Calculating the Total Delivery

First up we have the implementation to calculate the total delivery of an order:

<?php namespace PhilipBrown\Basket\MetaData;

use Money\Money;
use Money\Currency;
use PhilipBrown\Basket\Basket;
use PhilipBrown\Basket\MetaData;
use PhilipBrown\Basket\Reconciler;

class DeliveryMetaData implements MetaData
{
    /**
     * @var Reconciler
     */
    private $reconciler;

    /**
     * Create a new Delivery MetaData
     *
     * @param Reconciler $reconciler
     * @return void
     */
    public function __construct(Reconciler $reconciler)
    {
        $this->reconciler = $reconciler;
    }

    /**
     * Generate the Meta Data
     *
     * @param Basket $basket
     * @return mixed
     */
    public function generate(Basket $basket)
    {
        $total = new Money(0, $basket->currency());

        foreach ($basket->products() as $product) {
            $total = $total->add($this->reconciler->delivery($product));
        }

        return $total;
    }

    /**
     * Return the name of the Meta Data
     *
     * @return string
     */
    public function name()
    {
        return "delivery";
    }
}

In order to do the heavy lifting of the actual calculations we can inject an object that implements the Reconciler interface from last week.

First we create a new zero value Money instance.

Next we iterate over each product in the Basket and and add the value to the running total.

Calculating the Total Discount

Similar to the total delivery class, we have the total discount class:

<?php namespace PhilipBrown\Basket\MetaData;

use Money\Money;
use Money\Currency;
use PhilipBrown\Basket\Basket;
use PhilipBrown\Basket\MetaData;
use PhilipBrown\Basket\Reconciler;

class DiscountMetaData implements MetaData
{
    /**
     * @var Reconciler
     */
    private $reconciler;

    /**
     * Create a new Discount MetaData
     *
     * @param Reconciler $reconciler
     * @return void
     */
    public function __construct(Reconciler $reconciler)
    {
        $this->reconciler = $reconciler;
    }

    /**
     * Generate the Meta Data
     *
     * @param Basket $basket
     * @return mixed
     */
    public function generate(Basket $basket)
    {
        $total = new Money(0, $basket->currency());

        foreach ($basket->products() as $product) {
            $total = $total->add($this->reconciler->discount($product));
        }

        return $total;
    }

    /**
     * Return the name of the Meta Data
     *
     * @return string
     */
    public function name()
    {
        return "discount";
    }
}

Again we inject an instance of Reconciler to calculate the discount for each product.

Within the generate() method we create a new zero value Money instance and then iterate over each product in the basket and add it to the running total.

Calculating the Total Products Count

In order to count the total number of products in the basket we simply need to iterate over each product and count the quantity:

<?php namespace PhilipBrown\Basket\MetaData;

use PhilipBrown\Basket\Basket;
use PhilipBrown\Basket\MetaData;

class ProductsMetaData implements MetaData
{
    /**
     * Generate the Meta Data
     *
     * @param Basket $basket
     * @return mixed
     */
    public function generate(Basket $basket)
    {
        $total = 0;

        foreach ($basket->products() as $product) {
            $total = $total + $product->quantity;
        }

        return $total;
    }

    /**
     * Return the name of the Meta Data
     *
     * @return string
     */
    public function name()
    {
        return "products_count";
    }
}

We can implement this logic directly inside the generate() method without having to inject an instance of Reconciler.

Calculating the Subtotal

In order to calculate the subtotal of the order we can once again inject our trusty friend Reconciler:

<?php namespace PhilipBrown\Basket\MetaData;

use Money\Money;
use Money\Currency;
use PhilipBrown\Basket\Basket;
use PhilipBrown\Basket\MetaData;
use PhilipBrown\Basket\Reconciler;

class SubtotalMetaData implements MetaData
{
    /**
     * @var Reconciler
     */
    private $reconciler;

    /**
     * Create a new Subtotal MetaData
     *
     * @param Reconciler $reconciler
     * @return void
     */
    public function __construct(Reconciler $reconciler)
    {
        $this->reconciler = $reconciler;
    }

    /**
     * Generate the Meta Data
     *
     * @param Basket $basket
     * @return mixed
     */
    public function generate(Basket $basket)
    {
        $total = new Money(0, $basket->currency());

        foreach ($basket->products() as $product) {
            $total = $total->add($this->reconciler->subtotal($product));
        }

        return $total;
    }

    /**
     * Return the name of the Meta Data
     *
     * @return string
     */
    public function name()
    {
        return "subtotal";
    }
}

As with the discount and delivery classes, first we create a new zero value Money instance. Next we iterate over each product in the basket and add the returned value from the subtotal() method to the running total.

Calculating the Tax

Yet again, in order to calculate the total tax on an order, we simply inject an instance of Reconciler and then keep a running total:

<?php namespace PhilipBrown\Basket\MetaData;

use Money\Money;
use Money\Currency;
use PhilipBrown\Basket\Basket;
use PhilipBrown\Basket\MetaData;
use PhilipBrown\Basket\Reconciler;

class TaxMetaData implements MetaData
{
    /**
     * @var Reconciler
     */
    private $reconciler;

    /**
     * Create a new Tax MetaData
     *
     * @param Reconciler $reconciler
     * @return void
     */
    public function __construct(Reconciler $reconciler)
    {
        $this->reconciler = $reconciler;
    }

    /**
     * Generate the Meta Data
     *
     * @param Basket $basket
     * @return mixed
     */
    public function generate(Basket $basket)
    {
        $total = new Money(0, $basket->currency());

        foreach ($basket->products() as $product) {
            $total = $total->add($this->reconciler->tax($product));
        }

        return $total;
    }

    /**
     * Return the name of the Meta Data
     *
     * @return string
     */
    public function name()
    {
        return "tax";
    }
}

Calculating the count of taxable products

If we need to record how many of the products of the order were taxable, we can use the following meta data class:

<?php namespace PhilipBrown\Basket\MetaData;

use PhilipBrown\Basket\Basket;
use PhilipBrown\Basket\MetaData;

class TaxableMetaData implements MetaData
{
    /**
     * Generate the Meta Data
     *
     * @param Basket $basket
     * @return mixed
     */
    public function generate(Basket $basket)
    {
        $total = 0;

        foreach ($basket->products() as $product) {
            if ($product->taxable) {
                $total = $total + $product->quantity;
            }
        }

        return $total;
    }

    /**
     * Return the name of the Meta Data
     *
     * @return string
     */
    public function name()
    {
        return "taxable";
    }
}

In this implementation we simply iterate over each product in the basket and keep a running total of the number of products that are taxable.

Calculating the Total

In order to calculate the total of the order, we can use the following implementation:

<?php namespace PhilipBrown\Basket\MetaData;

use Money\Money;
use Money\Currency;
use PhilipBrown\Basket\Basket;
use PhilipBrown\Basket\MetaData;
use PhilipBrown\Basket\Reconciler;

class TotalMetaData implements MetaData
{
    /**
     * @var Reconciler
     */
    private $reconciler;

    /**
     * Create a new Total MetaData
     *
     * @param Reconciler $reconciler
     * @return void
     */
    public function __construct(Reconciler $reconciler)
    {
        $this->reconciler = $reconciler;
    }

    /**
     * Generate the Meta Data
     *
     * @param Basket $basket
     * @return mixed
     */
    public function generate(Basket $basket)
    {
        $total = new Money(0, $basket->currency());

        foreach ($basket->products() as $product) {
            $total = $total->add($this->reconciler->total($product));
        }

        return $total;
    }

    /**
     * Return the name of the Meta Data
     *
     * @return string
     */
    public function name()
    {
        return "total";
    }
}

Unsurprisingly we once again simply use the Reconciler class to calculate the total of each product of the basket and add it to the running total.

Calculating the Value

And finally, last, but not least, we have the implementation to calculate the value of the order:

<?php namespace PhilipBrown\Basket\MetaData;

use Money\Money;
use PhilipBrown\Basket\Basket;
use PhilipBrown\Basket\MetaData;
use PhilipBrown\Basket\Reconciler;

class ValueMetaData implements MetaData
{
    /**
     * @var Reconciler
     */
    private $reconciler;

    /**
     * Create a new Value MetaData
     *
     * @param Reconciler $reconciler
     * @return void
     */
    public function __construct(Reconciler $reconciler)
    {
        $this->reconciler = $reconciler;
    }

    /**
     * Generate the Meta Data
     *
     * @param Basket $basket
     * @return mixed
     */
    public function generate(Basket $basket)
    {
        $total = new Money(0, $basket->currency());

        foreach ($basket->products() as $product) {
            $total = $total->add($this->reconciler->value($product));
        }

        return $total;
    }

    /**
     * Return the name of the MetaData
     *
     * @return string
     */
    public function name()
    {
        return "value";
    }
}

Shockingly, we once again use the Reconciler class to encapsulate the logic of calculating the value of each product in the basket. In this class we simply create a zero value Money instance and then keep a running total as we add each product.

Testing the Meta Data Calculations

As I mentioned last week, in order to test this functionality I’ve created fixtures of different basket configurations.

I will then test each aspect of the package with each fixture.

I won’t show the tests here, because there is a lot of repeated code. But if you are interested in my approach to testing this package, please take a look at the GitHub repository.

Processing an Order

Once the customer is ready to buy, we need to process the basket of items and turn it into an Order.

The Order object is an immutable object that holds the final values of the processed basket of products.

This means the products have been reconciled, totals have been calculated and the meta data items have been generated and included.

Once the developer has created the Order object she can update the database and send the data to the payment gateway for processing.

The immutable Order object

So the first thing to do will be to create the immutable Order object. This should just be plain old PHP object:

<?php namespace PhilipBrown\Basket;

class Order
{
    /**
     * @var array
     */
    private $meta;

    /**
     * @var array
     */
    private $products;

    /**
     * Create a new Order
     *
     * @param array $meta
     * @param array $products
     * @return void
     */
    public function __construct(array $meta, array $products)
    {
        $this->meta = $meta;
        $this->products = $products;
    }

    /**
     * Return the meta
     *
     * @return array
     */
    public function meta()
    {
        return $this->meta;
    }

    /**
     * Return the products
     *
     * @return array
     */
    public function products()
    {
        return $this->products;
    }

    /**
     * Return the Order as an array
     *
     * @return array
     */
    public function toArray()
    {
        return array_merge($this->meta, ["products" => $this->products]);
    }
}

As you can see, this is a pretty plain old PHP object. When the object is instantiated we inject two arrays that hold the meta data items and the calculated product totals respectively.

The Order object also has a couple of methods for returning the array data and a toArray() method that will return all of the data as a single array.

Of course, due to the limitations of PHP, this object isn’t 100% immutable. However, it is important to represent this object as immutable because that is important to the ordering process.

Once the order has been generated, the developer should not alter it’s values. By instantiating the Order with the $meta and $products we are saying that the Order should not exist without both sets of data.

And by providing read-only methods, we are stating that this object should be immutable.

The Processor object

We now have everything in place to process a basket of products to produce a new order.

However, to make this process easier to use, we can create a Processor class that will encapsulate it. This means the developer does not need to worry about setting up this boilerplate code in her own project:

<?php namespace PhilipBrown\Basket;

class Processor
{
    /**
     * @var Reconciler
     */
    private $reconciler;

    /**
     * @var array
     */
    private $metadata;

    /**
     * Create a new Processor
     *
     * @param Reconciler $reconciler
     * @param array $metadata
     * @return void
     */
    public function __construct(Reconciler $reconciler, $metadata = [])
    {
        $this->reconciler = $reconciler;
        $this->metadata = $metadata;
    }
}

In order for the Process object to process an order it will need an instance of Reconciler and an array of MetaData objects to apply to the order. We can inject these two requirements on instantiation.

In order to use this class we can provide a process() method that accepts an instance of Basket:

/**
 * Process a Basket into Order
 *
 * @param Basket $basket
 * @return Order
 */
public function process(Basket $basket)
{
    $meta = $this->meta($basket);
    $products = $this->products($basket);

    return new Order($meta, $products);
}

As you can see, first we calculate the $meta and the $products totals.

Finally we can return a new instance of Order with the process totals as arguments to the __construct() method.

In order to calculate the meta data totals, we can use the following method:

/**
 * Process the Meta Data
 *
 * @param Basket $basket
 * @return array
 */
public function meta(Basket $basket)
{
    $meta = [];

    foreach ($this->metadata as $item) {
        $meta[$item->name()] = $item->generate($basket);
    }

    return $meta;
}

In this method we simply iterate over the $this->metadata array of MetaData instances and then pass the Basket to the generate() method.

Notice how we also use the name() of the MetaData class as the key of the array.

And finally we have the products() method:

/**
 * Process the Products
 *
 * @param Basket $basket
 * @return array
 */
public function products(Basket $basket)
{
    $products = [];

    foreach ($basket->products() as $product) {
        $products[] = [
            'sku' => $product->sku,
            'name' => $product->name,
            'price' => $product->price,
            'rate' => $product->rate,
            'quantity' => $product->quantity,
            'freebie' => $product->freebie,
            'taxable' => $product->taxable,
            'delivery' => $product->delivery,
            'coupons' => $product->coupons,
            'tags' => $product->tags,
            'discount' => $product->discount,
            'category' => $product->category,
            'total_value' => $this->reconciler->value($product),
            'total_discount' => $this->reconciler->discount($product),
            'total_delivery' => $this->reconciler->delivery($product),
            'total_tax' => $this->reconciler->tax($product),
            'subtotal' => $this->reconciler->subtotal($product),
            'total' => $this->reconciler->total($product)
        ];
    }

    return $products;
}

This method will iterate over each Product in the Basket to produce an array of values and calculated totals.

Conclusion

In today’s tutorial we saw how we can leave our code open for extension.

Calculating meta data about each order in an ecommerce application is likely to be different in every single ecommerce application.

Instead of limiting or forcing upon the developer certain meta data calculations, we can instead allow the developer to choose which meta data calculations she would like to include, or easily allow her to define her own.

It’s very easy to make your code closed as there is much less to think about. But leaving your code open for extension will greatly benefit the future version of you as well as anyone else who needs to use it.

If you want to see the full source code for the package that we’ve been building in this mini-series, take a look at the GitHub repository.

Philip Brown

@philipbrown

© Yellow Flag Ltd 2024.