Jun 11, 2014
Table of contents:
In last week’s tutorial, we looked at building a package that abstracts a lot of the problems associated with dealing with money and currency in software applications.
The Money package from last week is the basic building block of working with actual money objects and values, but if we were to build a fully fledged ecommerce website, we still need more than just these raw objects.
I think packages like my Money package are best suited to be consumed by other packages. By abstracting the problem of money and currency, we are free to use that building block package inside of another package without having to worry about those implementation details.
In this tutorial I’m going to be building a package to deal with products, orders and sales within a PHP ecommerce web application.
Before I get into the code, first I’ll explain what this package is going to do.
This package will solve the problem of dealing with multiple products during the order and sales process of completing a transaction within an ecommerce application.
Keeping track of multiple products within an ordering process can be difficult because you have to monitor totals, discounts, tax as well as many other variables. Added to that each different region you will support will usually have a different currency, tax rate and rules around which products are taxable or not.
This package will provide an easy to use interface for tracking multiple product orders so you can present these order details to the customer, but also track all of those individual details to aid your accounting system.
This package will consume the Money package from last week so I don’t have to solve that problem twice. The Money package is a dependency of this package, so we don’t need to see it because it will just do the work behind the scenes.
I’m going to skip the part about setting up the structure of the PHP package because I’ve already covered that in this article.
I’ve described how this package will theoretically work, but I’ll also describe how it will technically work before I get into the weeds of the implementation details.
The Merchant
class will act like a factory to create a new order.
When a new Order
is created it will be injected with a new Region
object that will encapsulate the details of a particular jurisdiction.
The Order
object will record the totals of the order and allow you to add, updated or remove products from the order.
Each product of the order will be an instance of Product
that will encapsulate all of the details of a particular product.
When a change occurs on the order (such as adding or removing a product), the order will reconcile to keep the totals correct.
The first class that I’m going to write for this package is a Helper
class that will provide two convenience methods that I will be using in the other main classes:
<?php namespace PhilipBrown\Merchant;
use Exception;
abstract class Helper
{
/**
* Convert a string to camelcase
*
* e.g hello_world -> helloWorld
*
* @param string $str
* @return string
*/
public static function camelise($str)
{
return preg_replace_callback(
"/_([a-z0-9])/",
function ($m) {
return strtoupper($m[1]);
},
$str
);
}
/**
* __get Magic Method
*
* @return mixed
*/
public function __get($param)
{
$method = "get" . ucfirst(self::camelise($param)) . "Parameter";
if (method_exists($this, $method)) {
return $this->{$method}();
}
throw new Exception("$param is not a valid property on this object");
}
}
I will be using this class as an abstract class to extend the other classes from. Arguably, this would be a good opportunity to use a Trait
, but I will be covering traits in a future tutorial. I think it is fine to use an abstract class in this case.
This class has two basic methods, one for turning a method name into it’s camalised form, and the __get()
magic method to return class properties.
Next I will create the specific Region
classes that will encapsulate details of a particular jurisdiction.
Firstly I will define a RegionInterface
:
<?php namespace PhilipBrown\Merchant;
interface RegionInterface
{
/**
* Get the name of the region
*
* @return string
*/
public function getNameParameter();
/**
* Get the currency of the region
*
* @return string
*/
public function getCurrencyParameter();
/**
* Check to see if tax is set in this region
*
* @return bool
*/
public function getTaxParameter();
/**
* Get the tax rate of the region
*
* @return int
*/
public function getTaxRateParameter();
}
I will also define an AbstractRegion
:
<?php namespace PhilipBrown\Merchant;
abstract class AbstractRegion extends Helper
{
/**
* The name of the region
*
* @var string
*/
protected $name;
/**
* The currency of the region
*
* @var string
*/
protected $currency;
/**
* Does this region have tax?
*
* @var bool
*/
protected $tax;
/**
* The tax rate of the region
*
* @var integer
*/
protected $taxRate;
/**
* Get the name of the region
*
* @return string
*/
public function getNameParameter()
{
return $this->name;
}
/**
* Get the currency of the region
*
* @return string
*/
public function getCurrencyParameter()
{
return $this->currency;
}
/**
* Check to see if tax is set in this region
*
* @return bool
*/
public function getTaxParameter()
{
return $this->tax;
}
/**
* Get the tax rate of the region
*
* @return integer
*/
public function getTaxRateParameter()
{
return $this->taxRate;
}
/**
* Return the region as a string
*
* @return string
*/
public function __toString()
{
return $this->getNameParameter();
}
}
And finally I will define a region for England:
<?php namespace PhilipBrown\Merchant\Region;
use PhilipBrown\Merchant\AbstractRegion;
use PhilipBrown\Merchant\RegionInterface;
class England extends AbstractRegion implements RegionInterface
{
/**
* @var string
*/
protected $name = "England";
/**
* @var string
*/
protected $currency = "GBP";
/**
* @var boolean
*/
protected $tax = true;
/**
* @var integer
*/
protected $taxRate = 20;
}
Now if I wanted to add additional regions, I would just have to create new child classes and add the required details for that jurisdiction.
The main entry point to this package will be through the Merchant
factory:
<?php namespace PhilipBrown\Merchant;
use PhilipBrown\Merchant\Exception\InvalidRegionException;
class Merchant
{
/**
* Create a new Order
*
* @param string $region
* @return PhilipBrown\Merchant\Order
*/
public static function order($region)
{
$class = "PhilipBrown\Merchant\Region\\" . ucfirst($region);
if (class_exists($class)) {
return new Order(new $class());
}
throw new InvalidRegionException("$region is not a valid region");
}
}
This class has a single method which accepts a string for the name of the region.
This class will attempt to find the region class by creating a string and using the class_exists()
function. If the class does exist we can instantiate a new Order
and inject the region.
If the region class could not be found, an InvalidRegionException
will be thrown.
This exception class is your basic custom exception:
<?php namespace PhilipBrown\Merchant\Exception;
use Exception;
class InvalidRegionException extends Exception
{
}
The majority of the heavy lifting occurs in the Order
class:
<?php namespace PhilipBrown\Merchant;
use PhilipBrown\Money\Money;
use PhilipBrown\Merchant\RegionInterface;
use PhilipBrown\Merchant\Exception\InvalidOrderException;
class Order extends Helper
{
}
The first thing I will do it define the __construct()
method and set the class properties:
/**
* The region of the Order
*
* @var RegionInterface
*/
protected $region;
/**
* The products of the order
*
* @var array
*/
protected $products;
/**
* The cache of the products for easy lookups
*
* @var array
*/
protected $products_cache;
/**
* The total value of the order
*
* @var Money
*/
protected $total_value;
/**
* The total discount of the order
*
* @var Money
*/
protected $total_discount;
/**
* The total tax of the order
*
* @var Money
*/
protected $total_tax;
/**
* The subtotal of the order
*
* @var Money
*/
protected $subtotal;
/**
* The total of the order
*
* @var Money
*/
protected $total;
/**
* Does the order need to be reconciled?
*
* @var boolean
*/
protected $dirty;
/**
* Create a new Order
*
* @param RegionInterface $region
* @return void
*/
public function __construct(RegionInterface $region)
{
$this->region = $region;
$this->dirty = true;
}
The class properties should be pretty self explanatory, but if you are unsure of any of them at this stage, don’t worry, I’ll be covering each one as I talk about each method of the class.
The __construct()
method accepts an object that implements the RegionInterface
.
I’m also setting the $this->dirty
class property to true. This property regulates whether the totals need to be reconciled or not. I’ve set it to true
by default so the object knows it needs to reconcile before giving totals.
Before getting into the individual methods of the Order
class, I think it makes sense to create the Product
class first:
<?php namespace PhilipBrown\Merchant;
use Closure;
use PhilipBrown\Money\Money;
use PhilipBrown\Merchant\Exception\InvalidProductException;
class Product extends Helper
{
}
The Product
class is a Value Object that will encapsulate the details of a particular type of product within the order.
The first thing to do is to define the class properties and the __construct()
method:
/**
* The product's stock keeping unit
*
* @var string
*/
protected $sku;
/**
* The product's currency
*
* @var string
*/
protected $currency;
/**
* The value of the product
*
* @var Money
*/
protected $value;
/**
* Is this product taxable?
*
* @var boolean
*/
protected $taxable;
/**
* The tax rate of the product
*
* @var integer
*/
protected $taxRate;
/**
* The amount of tax
*
* @var Money
*/
protected $tax;
/**
* The value of the discount
*
* @var Money
*/
protected $discount;
/**
* The quantity of the current product
*
* @var int
*/
protected $quantity;
/**
* Is this product a freebie?
*
* @var bool
*/
protected $freebie;
/**
* The coupon that is associated with this product
*
* @var string
*/
protected $coupon;
/**
* Construct
*
* @param string $sku
* @param int $value
* @param PhilipBrown\Money\Currency $currency
* @param bool $taxable
* @param int $taxRate
* @return void
*/
public function __construct($sku, $value, $currency, $taxable, $taxRate)
{
$this->sku = $sku;
$this->currency = $currency;
$this->value = Money::init($value, $currency);
$this->taxable = $taxable;
$this->taxRate = $taxRate;
$this->discount = Money::init(0, $currency);
$this->quantity = 1;
$this->freebie = false;
$this->tax = $this->calculateTax();
$this->total = $this->calculateTotal();
}
The $sku
, $value
, $currency
, $taxable
and $taxRate
will be injected into the class when a new product is created inside the Order
.
The remaining class properties are set to default values within the __construct()
method.
Finally the $this->tax
property is set by the $this->calculateTax()
method and the $this->total
property is set by the $this->calculateTotal()
method.
The $this->calculateTax()
method looks like this:
/**
* Set the total depending on whether this product is a freebie
*
* @return Money
*/
protected function calculateTotal()
{
return $this->total = ($this->freebie) ? Money::init(0, $this->currency) : $this->value;
}
And the $this->calculateTax()
method looks like this:
/**
* Calculate the tax of the product
*
* @return Money
*/
protected function calculateTax()
{
if ($this->taxable) {
$total = $this->value->subtract($this->discount);
return $total->multiply($this->taxRate / 100);
}
return Money::init(0, $this->currency);
}
These are just helper methods that will be used to set totals throughout this class. You’ll notice that I’m not multiplying these values by the $quantity
. This Value Object just holds details of the product, those calculations are performed in the Order
class.
When a new product is added to an order, there are a couple of actions a user will want to take. For example, the product might have a discount code, or this particular product might not be taxable.
In order to allow the user to specify these details, I want to be able to accept either an Array
or a Closure
of actions to perform.
The syntax of this code would be as follows:
// Using an array
$order->add("456", 1000, [
"discount" => 200,
"coupon" => "SUMMERSALE2014",
]);
// Using a closure
$order->add("789", 1000, function ($item) {
$item->taxable(false);
$item->quantity(10);
});
The action
is the third parameter of the add()
method on the Order
class and should accept either an Array
or a Closure
. This action
will then be passed on to the following method on the Product
object to be actioned:
/**
* Accept an array or a Closure of actions to run on the product
*
* @param Closure|array $action
* @return bool
*/
public function action($action)
{
if (is_array($action)) {
return $this->runActionArray($action);
}
if ($action instanceof Closure) {
return $this->runActionClosure($action);
}
throw new InvalidProductException('The action must be an array or a closure');
}
This method will simply determine what type of action has been passed as an argument and then delegate it to the specific method to action it.
If the action is not an Array
or a Closure
an InvalidProductException
exception will be thrown. Again this is just a simple exception class:
<?php namespace PhilipBrown\Merchant\Exception;
use Exception;
class InvalidProductException extends Exception
{
}
The runActionArray()
and runActionClosure()
are as follows:
/**
* Run an array of actions
*
* @param array $action
* @return bool
*/
protected function runActionArray(array $action)
{
foreach ($action as $k => $v) {
$method = 'set'.ucfirst(self::camelise($k)).'Parameter';
if (method_exists($this, $method)) $this->{$method}($v);
}
return true;
}
/**
* Run a Closure of actions
*
* @param Closure $action
* @return bool
*/
protected function runActionClosure($action)
{
call_user_func($action, $this);
return true;
}
The runActionClosure()
method will spin through each key and value of the array and attempt to run the specific method if it has been defined.
The runActionClosure()
method will run the Closure
and pass in an instance of itself as the only argument.
Next we need to define setter methods that will allow us to manipulate the properties of the class. Each setter will have a friendly helper method that will delegate to the actual method that will perform the action. The friendly helper method is used as a nicer syntax when running inside of the Closure
action.
The Quantity methods are:
/**
* Quantity helper method
*
* @param integer $value
* @return void
*/
public function quantity($value)
{
$this->setQuantityParameter($value);
}
/**
* Set the quantity parameter
*
* @param int $value
* @return int
*/
protected function setQuantityParameter($value)
{
if (is_int($value)) {
return $this->quantity = $value;
}
throw new InvalidProductException('The quantity property must be an integer');
}
The Taxable methods are:
/**
* Taxable helper method
*
* @param boolean $value
* @return void
*/
public function taxable($value)
{
$this->setTaxableParameter($value);
}
/**
* Set the taxable parameter
*
* @param boolean $value
* @return Money
*/
protected function setTaxableParameter($value)
{
if (is_bool($value)) {
$this->taxable = $value;
return $this->tax = $this->calculateTax();
}
throw new InvalidProductException('The taxable property must be a boolean');
}
The Discount methods are:
/**
* Discount helper method
*
* @param integer $value
* @return void
*/
public function discount($value)
{
$this->setDiscountParameter($value);
}
/**
* Set the discount parameter
*
* @param integer $value
*/
protected function setDiscountParameter($value)
{
if (is_int($value)) {
$this->discount = Money::init($value, $this->currency);
return $this->tax = $this->calculateTax();
}
throw new InvalidProductException('The discount property must be an integer');
}
The Freebie methods are:
/**
* Freebie helper method
*
* @param boolean $value
* @return void
*/
public function freebie($value)
{
$this->setFreebieParameter($value);
}
/**
* Set the freebie parameter
*
* @param boolean $value
*/
protected function setFreebieParameter($value)
{
if (is_bool($value)) {
$this->freebie = $value;
$this->taxable = ($this->freebie) ? false : $this->taxable;
$this->tax = ($this->freebie) ? Money::init(0, $this->currency) : $this->calculateTax();
$this->discount = ($this->freebie) ? Money::init(0, $this->currency) : $this->discount;
return $this->calculateTotal();
}
throw new InvalidProductException('The freebie property must be a boolean');
}
And the Coupon methods are:
/**
* Coupon helper method
*
* @param string $value
*/
public function coupon($value)
{
$this->setCouponParameter($value);
}
/**
* Set the coupon parameter
*
* @param string $value
*/
protected function setCouponParameter($value)
{
if (is_string($value)) {
return $this->coupon = $value;
}
throw new InvalidProductException('The coupon property must be a string');
}
Each of these methods do a little bit of type hinting and will throw an exception if the right argument type has not been provided.
A couple of the methods also need to reset some of the totals depending on the argument that was provided. That is why we have the calculateTax()
and calculateTotal()
methods from earlier.
Finally we have a number of getter methods that will return the value of a particular class property:
/**
* Get the sku parameter
*
* @return string
*/
protected function getSkuParameter()
{
return $this->sku;
}
/**
* Get currency parameter
*
* @return string
*/
protected function getCurrencyParameter()
{
return $this->currency;
}
/**
* Get the value parameter
*
* @return integer
*/
protected function getValueParameter()
{
return $this->value;
}
/**
* Get the taxable parameter
*
* @return boolean
*/
protected function getTaxableParameter()
{
return $this->taxable;
}
/**
* Get the tax rate parameter
*
* @return integer
*/
protected function getTaxRateParameter()
{
return $this->taxRate;
}
/**
* Get the tax parameter
*
* @return Money
*/
protected function getTaxParameter()
{
return $this->tax;
}
/**
* Get the quantity parameter
*
* @return integer
*/
protected function getQuantityParameter()
{
return $this->quantity;
}
/**
* Get the discount parameter
*
* @return Money
*/
protected function getDiscountParameter()
{
return $this->discount;
}
/**
* Get the freebie parameter
*
* @return boolean
*/
protected function getFreebieParameter()
{
return $this->freebie;
}
/**
* Get the coupon parameter
*
* @return string
*/
protected function getCouponParameter()
{
return $this->coupon;
}
Right! Now that we’ve created the Product
class we can start to implement the main methods of the Order
class.
The first method we’ll look at is the add()
method:
/**
* Add a product to the order
*
* @param string $sku
* @param integer $value
* @param Closure|array $action
* @return boolean
*/
public function add($sku, $value, $action = null)
{
$product = new Product(
$sku,
$value,
$this->region->currency,
$this->region->tax,
$this->region->taxRate
);
if (!is_null($action)) $product->action($action);
$this->products[] = $product;
$this->products_cache[] = $sku;
$this->dirty = true;
return true;
}
The add()
method accepts a $sku
, a $value
and an optional $action
. The SKU is just the application’s internal reference for that product.
We can then create a new instance of Product
and pass in the required details of the $sku
, the $value
and a couple of details from the region.
If the $action
has been set, we can pass the $action
to the action()
method on the Product
instance.
Next we can add the $product
to the array of $products
as well as the $product_cache
. The cache is just a quicker way of finding a product by it’s $sku
.
And finally we can set $this->dirty
to be true
so the Order
knows it must reconcile the totals again.
The remove method will allow you to completely remove a product from the order:
/**
* Remove a product from the order
*
* @param string $sku
* @return boolean
*/
public function remove($sku)
{
if (in_array($sku, $this->products_cache)) {
$key = array_search($sku, $this->products_cache);
unset($this->products[$key]);
unset($this->products_cache[$key]);
$this->products = array_values($this->products);
$this->products_cache = array_values($this->products_cache);
$this->dirty = true;
return true;
}
throw new InvalidOrderException("$sku was not found in the products list");
}
The remove()
method accepts the product’s $sku
as an argument.
Firstly we check to see if the $sku
is in the $products_cache
. If it is not we can throw an InvalidOrderException
. Again, this is just a simple custom exception the same as the others:
<?php namespace PhilipBrown\Merchant\Exception;
use Exception;
class InvalidOrderException extends Exception
{
}
If the product is part of the order we can retrieve the key and unset the product from the $this->products
array and the $this->products_cache
. Next we can reset the values of each of the arrays by calling the array_values
function.
And finally we can set $this->dirty
to be true
to force the reconciliation.
The update()
method is basically the same as the add()
method, but before adding the product, first we’ll completely remove the existing product:
/**
* Update an existing product
*
* @param string $sku
* @param integer $value
* @param Closure|array $action
*/
public function update($sku, $value, $action = null)
{
if ($this->remove($sku)) {
$product = new Product(
$sku,
$value,
$this->region->currency,
$this->region->tax,
$this->region->taxRate
);
if (!is_null($action)) $product->action($action);
$this->products[] = $product;
$this->products_cache[] = $sku;
$this->dirty = true;
return true;
}
}
The reconcile()
method will be called before any totals are returned from the order:
/**
* Reconcile the order if it is dirty
*
* @return void
*/
public function reconcile()
{
if ($this->dirty) {
$total_value = 0;
$total_discount = 0;
$total_tax = 0;
$subtotal = 0;
foreach ($this->products as $product) {
$i = $product->quantity;
while($i > 0)
{
$total_value = $total_value + $product->value->cents;
$total_discount = $total_discount + $product->discount->cents;
$total_tax = $total_tax + $product->tax->cents;
$subtotal = $subtotal + $product->total->cents;
$i-;
}
}
$this->total_value = Money::init($total_value, $this->region->currency);
$this->total_discount = Money::init($total_discount, $this->region->currency);
$this->total_tax = Money::init($total_tax, $this->region->currency);
$this->subtotal = Money::init(($subtotal), $this->region->currency);
$this->total = Money::init(($subtotal - $this->total_discount->cents + $this->total_tax->cents), $this->region->currency);
$this->dirty = false;
}
}
First we can check to see if the order is dirty. If the order is not dirty we can just skip the reconciliation process.
Next we will set the totals to a default of 0
and then loop through each of the products and add the values.
Finally we can create the totals as instances of Money
with the correct currency taken from the $this->region
and set $this->dirty
to be false.
For each of the totals, the getter method will first run the reconcile()
method to ensure the totals are up-to-date:
/**
* Get the total parameter
*
* @return Money
*/
protected function getTotalValueParameter()
{
$this->reconcile();
return $this->total_value;
}
/**
* Get the total discount parameter
*
* @return Money
*/
protected function getTotalDiscountParameter()
{
$this->reconcile();
return $this->total_discount;
}
/**
* Get the total tax parameter
*
* @return Money
*/
protected function getTotalTaxParameter()
{
$this->reconcile();
return $this->total_tax;
}
/**
* Get the subtotal parameter
*
* @return Money
*/
protected function getSubtotalParameter()
{
$this->reconcile();
return $this->subtotal;
}
/**
* Get the total parameter
*
* @return Money
*/
protected function getTotalParameter()
{
$this->reconcile();
return $this->total;
}
I will also provide a couple of getter methods to return the $this->region
instance and the array of $this->products
.
/**
* Get the region parameter
*
* @return string
*/
protected function getRegionParameter()
{
return $this->region;
}
/**
* Get the products parameter
*
* @return array
*/
protected function getProductsParameter()
{
return $this->products;
}
Tests are an important part of a package like this because if we make a change to the code, we want to know that we haven’t accidentally broken another aspect. Shipping a broken package like this into your production code could be nasty.
First we’ll check the Merchant
class:
use PhilipBrown\Merchant\Merchant;
class MerchantTest extends PHPUnit_Framework_TestCase
{
/**
* @expectedException PhilipBrown\Merchant\Exception\InvalidRegionException
* @expectedExceptionMessage Nuh huh is not a valid region
*/
public function testExceptionOnInvalidRegion()
{
$m = new Merchant();
$m->order("Nuh huh");
}
public function testInstantiateValidRegion()
{
$o = Merchant::order("England");
$this->assertInstanceOf("PhilipBrown\Merchant\Order", $o);
$this->assertInstanceOf(
"PhilipBrown\Merchant\RegionInterface",
$o->region
);
$this->assertEquals("England", $o->region);
$this->assertEquals("GBP", $o->region->currency);
$this->assertTrue($o->region->tax);
$this->assertEquals(20, $o->region->tax_rate);
}
}
Here I’m just checking that only an order can only be created with a valid region and the basic Order
API is working correctly for returning information about the current region.
To test the Product
class I’ve just written tests to cover as many different scenarios as I can think of. Here I’m basically just setting up the order and asserting that everything looks correct:
public function testAddProductWithActionArrayAndQuantityProperty()
{
$o = Merchant::order('England');
$o->add('123', 1000, array(
'quantity' => 2
));
$this->assertEquals(2, $o->products[0]->quantity);
}
I’m also testing that the correct exceptions are triggered:
/**
* @expectedException PhilipBrown\Merchant\Exception\InvalidProductException
* @expectedExceptionMessage The action must be an array or a closure
*/
public function testInvalidActionTypeException()
{
$o = Merchant::order('England');
$o->add('123', 233, 'action');
}
To see the full range of Product
tests, click here.
And finally, to test the Order
class I once again set up various scenarios and assert that the totals are as I would expect them to be:
public function testCorrectSubtotalProperty()
{
$o = Merchant::order('England');
$o->add('123', 1000);
$o->add('456', 2000, array(
'discount' => 500
));
$o->add('789', 600, array(
'freebie' => true
));
$this->assertEquals(3000, $o->subtotal->cents);
}
To see the full range of Order
tests, click here.
Phew! That was a long article! Well done if you made it this far.
Hopefully if you are building an ecommerce application you will find this package really helpful for building orders and tracking the various totals that you need to monitor and record when a customer buys something from you. If you want to use this package in one of your projects, the source code is available on GitHub.
But even if you aren’t building an ecommerce application right now, hopefully you will be able to take something away from this approach.
Did you notice that I didn’t once have to think about dealing with money values, currencies, rounding errors or ensuring that my money values were value objects? It was as if PHP had native money object support!
I think the most important thing to take away from this tutorial is, when we abstract a problem like money and currency into it’s own package, that package can then easily be consumed by other packages.
By building those basic foundational blocks, we can consume and build even better packages and applications without ever having to worry about those underlying implementation details.
So the next time you face a problem like money and currency, think of a way of abstracting the problem into it’s own, self contained package that then can be consumed by many other packages. Solving the problem once is a beautiful way of dealing with problems that crop up again and again.