In a previous article, we described how to develop a custom Sales Rule for Magento 2. Today we’ll expand on this rule to make it more flexible and able to cover more situations.
Let’s say that you want to reward faithful customers with promotions. In this case, you want to reward clients for making their third purchase and/or achieving a lifetime total purchase value of at least 500, and doing it within a predefined time period of 6 months.
We want the final result to like this:
What’s new?
We’ll start by revisiting the previous article and then expand on it. After reading it, you will learn:
- how to create a combined condition with its sub-conditions
- how to prepare custom HTML output for a condition
- how to perform such combined condition validation
Registering the module
Just like the last time, let’s start by preparing the module:
//file: app/code/Magently/CustomerRule/registration.php
<?php
\Magento\Framework\Component\ComponentRegistrar::register(
\Magento\Framework\Component\ComponentRegistrar::MODULE,
'Magently_CustomerRule',
__DIR__
);
In module.xml, sequence the module after Magento_SalesRule:
// file: app/code/Magently/CustomerRule/etc/module.xml
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:Module/etc/module.xsd">
<module name="Magently_CustomerRule" setup_version="1.0.0">
<sequence>
<module name="Magento_SalesRule"/>
</sequence>
</module>
</config>
Observer: adds our condition
Now, set up an observer for salesrule_rule_condition_combine event:
// file: app/code/Magently/CustomerRule/etc/adminhtml/events.xml
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Event/etc/events.xsd">
<event name="salesrule_rule_condition_combine">
<observer name="customer_rule" instance="Magently\CustomerRule\Observer\ReturningCustomerConditionObserver" />
</event>
</config>
And then for the Magently\CustomerRule\Observer\ReturningCustomerConditionObserver class:
// file: app/code/Magently/CustomerRule/Observer/ReturningCustomerConditionObserver.php
<?php
namespace Magently\CustomerRule\Observer;
use Magento\Framework\Event\ObserverInterface;
use Magento\Framework\Event\Observer;
use Magently\CustomerRule\Model\Rule\Condition\ReturningCustomer;
/**
* Class CustomerConditionObserver - adds a new condition to the conditions’ list
*/
class ReturningCustomerConditionObserver implements ObserverInterface
{
/**
* Execute observer
*
* @param Observer $observer
* @return $this
*/
public function execute(Observer $observer)
{
$additional = $observer->getAdditional();
$conditions = (array) $additional->getConditions();
$conditions = array_merge_recursive($conditions, [
$this->getReturningCustomerCondition(),
]);
$additional->setConditions($conditions);
return $this;
}
/**
* Get a condition for a returning customer
*
* @return array
*/
private function getReturningCustomerCondition()
{
return [
'label'=> __('Returning Customer'),
'value'=> ReturningCustomer::class
];
}
}
The observer fetches the array of existing conditions, merges it with our custom condition (as set in getReturningCustomerCondition() method), and passes the updated array back to conditions DataObject. In getReturningCustomerCondition(), we set the condition as an array with two keys: ‘label’: name of the condition that will be shown in the admin area, and ‘value’: a class that will handle the condition.
How do Condition classes work?
Before we prepare the condition, we need a bit of information on what is a Condition class. It’s important to know that they are standard Magento DataObjects and are loaded with various properties like value or operator. What’s distinctive about them is that they have service methods that work with those properties, for example:
- getValueElementType() will specify whether value will be presented as – for example – text field (if returns ‘text’) or select (if returns ‘select’),
- loadValueOptions() will populate value select field for value with options,
- getValueElement()->getHtml() will display value form field in admin area,
and many more.
ReturningCustomer condition class
Finaly we’re ready to prepare a condition. As it’s a fairly big class, we’ll split the code below into chunks for clarity. First: class declaration and constructor:
Class declaration and constructor
// file: app/code/Magently/CustomerRule/Model/Rule/Condition/ReturningCustomer
<?php
namespace Magently\CustomerRule\Model\Rule\Condition;
use Magento\Rule\Model\Condition\Combine;
use Magento\Rule\Model\Condition\Context;
use Magento\Framework\Model\AbstractModel;
use Magento\Framework\Message\ManagerInterface;
/**
* Class ReturningCustomer - a condition for returning customer
*/
class ReturningCustomer extends Combine
{
/**
* @var ManagerInterface $messageManager
*/
private ManagerInterface $messageManager;
/**
* @param Context $context
* @param ManagerInterface $messageManager
* @param array $data
*/
public function __construct(Context $context, ManagerInterface $messageManager, array $data = [])
{
$this->messageManager = $messageManager;
parent::__construct($context, $data);
$this->setType(self::class);
}
...
After doing some imports (which will be used later) – we declare our class as a child of Magento\Rule\Model\Condition\Combine and not base Magento\Rule\Model\Condition\AbstractCondition which enables us to use sub-conditions. setType() method is called after parent’s constructor, as we need to override ‘type’ property set in parent’s constructor.
Combine parent will bring us two interesting properties with their service methods: – aggregator – has standard values ‘all’ / ‘any’, decides on how to validate attached sub-conditions (should all sub-conditions be met or just one of them) – conditions – contains array of sub-conditions
Combine inherits from Magento\Rule\Model\Condition\AbstractCondition therefore standard properties are also available: value, operator and attribute.
Sub-condition setup
// file: app/code/Magently/CustomerRule/Model/Rule/Condition/ReturningCustomer
...
/**
* Create custom html for condition
*
* @return string
*/
public function asHtml()
{
return $this->getTypeElement()->getHtml() .
__(
'If customer within %1 last %2 has met %3 of the conditions:',
$this->getValueElement()->getHtml(),
$this->getAttributeElement()->getHtml(),
$this->getAggregatorElement()->getHtml(),
) .
$this->getRemoveLinkHtml();
}
...
Method getNewChildSelectOptions() populates the list of sub-conditions that will be available for merchant to choose. Of course you can include preexisting conditions, in our case we’ll create new condition labeled Customer’s purchase history. Its logic will be provided by Magently\CustomerRule\Model\Rule\Condition\CustomerPurchaseHistory class that we will develop in next step. Again our array contains two keys: ‘value’ – a class responsible for handling subcondition and ‘label’ – name of the subcondition shown to merchant in admin area.
Value setup
Base AbstractCondition class sets value element type to ‘text’, but Combine class overrides it to TRUE/FALSE select. We need to revert it to ‘text’ and clear its options, as we want to use ‘value’ to store number and be presented as text field:
// file: app/code/Magently/CustomerRule/Model/Rule/Condition/ReturningCustomer
...
/**
* @return string
*/
public function getValueElementType()
{
return 'text';
}
/**
* @return $this|ReturningCustomer
*/
public function loadValueOptions()
{
$this->setValueOption([]);
return $this;
}
...
Attribute setup
We will use attribute as a time interval selector. Therefore we need to set up its’ options:
// file: app/code/Magently/CustomerRule/Model/Rule/Condition/ReturningCustomer
...
/**
* Populate attribute options with time intervals
*
* @return $this|ReturningCustomer
*/
public function loadAttributeOptions()
{
$this->setAttributeOption([
'hours' => __('hour(s)'),
'days' => __('day(s)'),
'months' => __('month(s)'),
'years' => __('year(s)')
]);
return $this;
}
...
HTML output for admin area
Standart Combine condition presents itself in admin area like this:
To adjust it to our needs we override asHtml() method:
// file: app/code/Magently/CustomerRule/Model/Rule/Condition/ReturningCustomer
...
/**
* Create custom html for condition
*
* @return string
*/
public function asHtml()
{
return $this->getTypeElement()->getHtml() .
__(
'If customer within %1 last %2 has met %3 of the conditions:',
$this->getValueElement()->getHtml(),
$this->getAttributeElement()->getHtml(),
$this->getAggregatorElement()->getHtml(),
) .
$this->getRemoveLinkHtml();
}
...
Finally the condition should look like this:
Save the data
LoadArray() method adds various properties to our condition DataObject i.e. when promo rule is being saved. We do two things here:
- value validation: if ‘value’ is not a number or if it’s less than 1 – we set it to 1 and output message using Magento\Framework\Message\ManagerInterface that we loaded in the constructor
- setting ‘attribute’ back again – another thing we need to override as we inherit from Combine (that gets rid of ‘attribute’) and not AbstractCondition
// file: app/code/Magently/CustomerRule/Model/Rule/Condition/ReturningCustomer
...
/**
* Add attribute to object
* If inserted value is not number or equals 0 - set it to 1 and output message
*
* @param array $arr
* @param string $key
* @return ReturningCustomer|void
*/
public function loadArray($arr, $key = 'conditions') // phpcs:ignore
{
if (!is_numeric($arr['value']) || $arr['value'] < 1) {
$arr['value'] = 1;
$this->messageManager->addErrorMessage(
__('Please specify time interval for returning customer condition as number greater than 0.')
);
}
parent::loadArray($arr, $key);
$this->setAttribute($arr['attribute']);
}
...
Validation
The validate($model) method fires on the frontend’s ‘checkout/cart’ page when conditions are checked and Magento decides whether to grant a promotion to a customer. It returns boolean, with ‘true’ meaning – promo rule should be applied. Its parameter $model is in fact Magento\Quote\Model\Quote\Address (or its interceptor ). Inherited from Combine::validate() does great job with aggregating validations done by the sub-conditions.
We just need to do some final touches:
- if the purchase is made by an unlogged customer (the model does not contain ‘customer_id’ data) – return false, and validation is done. This part could be removed, depending on scenario.
- Then we add ‘time_limit’ data to our model. We’ll use a private method getTimeLimit() to prepare the time limit, –
- Such an enhanced model is then passed to parent::validate($model) where the rest of the job is done.
// file: app/code/Magently/CustomerRule/Model/Rule/Condition/ReturningCustomer
...
/**
* Validation:
* - if customer is not logged in - validate false
* - then feed model with time limit
* - and perform parent class Combine::validate()
*
* @param AbstractModel $model
* @return boolean
*/
public function validate(AbstractModel $model)
{
if (!$model->getCustomerId()) {
return false;
}
$model->setTimeLimit($this->getTimeLimit());
return parent::validate($model);
}
/**
* Prepare date string in mysql format
*
* @return false|string
*/
private function getTimeLimit()
{
return date('Y-m-d H:i:s', strtotime(sprintf(
'%s %s ago',
$this->getValue(),
$this->getAttribute()
)));
}
}
In getTimeLimit(), translate the “Within 3 last month(s)” phrase into valid SQL time string. Let’s use ‘value’ (a number) and ‘attribute’ (a string like ‘days’ or ‘months’) to prepare a relative time format string like ‘3 months ago’. Such format is then passed to PHP native strtotime() to create a timestamp, which finally is formatted by PHP’s date() to SQL format.
Customer History
In ReturningCustomer::getNewChildSelectOptions() we set CustomerPurchaseHistory as a class that will handle sub-conditions for our combined condition. Here’s how we’ll develop this class:
Set up:
// file: app/code/Magently/CustomerRule/Model/Rule/Condition/CustomerPurchaseHistory
<?php
namespace Magently\CustomerRule\Model\Rule\Condition;
use Magento\Framework\Exception\LocalizedException;
use Magento\Rule\Model\Condition\AbstractCondition;
use Magento\Rule\Model\Condition\Context;
use Magento\Sales\Model\ResourceModel\Order;
use Magento\Quote\Api\Data\AddressInterface;
use Magento\Sales\Api\Data\OrderInterface;
use Magento\Framework\Model\AbstractModel;
/**
* Class CustomerPurchaseHistory - a subcondition for customer purchase history
*/
class CustomerPurchaseHistory extends AbstractCondition
{
/**
* @var array APPLICABLE_ORDER_STATES
*/
const APPLICABLE_ORDER_STATES = ['closed', 'complete'];
/**
* @var array SQL_FUNCTIONS
*/
const SQL_FUNCTIONS = [
'customer_history_value' => 'SUM',
'customer_history_amount' => 'COUNT'
];
/**
* @var Order $order
*/
private Order $order;
/**
* @param Context $context
* @param Order $order
* @param array $data
*/
public function __construct(Context $context, Order $order, array $data = [])
{
$this->order = $order;
parent::__construct($context, $data);
}
/**
* Load attribute options
*
* @return $this
*/
public function loadAttributeOptions()
{
$this->setAttributeOption([
'customer_history_value' => __('Customer\'s total purchase value'),
'customer_history_amount' => __('Customer\'s amount of purchases'),
]);
return $this;
}
...
Again – the purpose of each import will be revealed later, as well as the constants. The Class will be a simple condition, therefore it inherits from the base Magento\Rule\Model\Condition\AbstractCondition. In a constructor, inject Order resource model, that will later be used to fetch suitable data from the DB. The merchant should be able to set a condition either as the customer’s total purchase value or the total amount of purchases (or both), so we set options as an array of two respective attributes in the loadAttributeOptions() method.
Validation
Lastly, we have to validate the condition. The general idea of the validate() method of conditions inheriting from Magento\Rule\Model\Condition\AbstractCondition is simple:
- fetch your data
- assign it to your models’ attribute key
- pass the model to a parent::validate().
There, the assigned attribute value will be validated against the operator (whether the value should be greater than / equal etc).
In our case validation could look like this:
// file: app/code/Magently/CustomerRule/Model/Rule/Condition/CustomerPurchaseHistory
...
/**
* Validate Customer History Condition
*
* @param AbstractModel $model
* @return boolean
* @throws LocalizedException
*/
public function validate(AbstractModel $model)
{
$model->setData($this->getAttribute(), $this->getCustomerHistoryValue($model));
return parent::validate($model);
}
/**
* Return value for specific attribute
*
* @param AddressInterface $model
* @return integer|string
* @throws LocalizedException
*/
private function getCustomerHistoryValue(AddressInterface $model)
{
$select = $this->order->getConnection()
->select()
->from(
$this->order->getMainTable(),
sprintf(
'%s(%s)',
self::SQL_FUNCTIONS[$this->getAttribute()],
OrderInterface::BASE_TOTAL_PAID
)
)
->where(sprintf('%s=%d', OrderInterface::CUSTOMER_ID, $model->getCustomerId()))
->where(sprintf('%s IN (?)', OrderInterface::STATE), self::APPLICABLE_ORDER_STATES);
if ($model->hasTimeLimit()) {
$select->where(sprintf('%s >= "%s"', OrderInterface::CREATED_AT, $model->getTimeLimit()));
}
return $this->order->getConnection()->fetchOne($select) ?? 0;
}
}
In validate(), assign data to the model’s key ‘customer_history_value’ or ‘customer_history_amount’ (because these are the attributes we specified before). To get an actual value, use a private method getCustomerHistoryValue(), which fetches data from the database. I decided to use a ResourceModel::getConnection() method as it returns a fully customizable lightweight sql query, that at the end is executed.
Some remarks on getCustomerHistoryValue() method:
- from() call’s second argument is a string: COUNT(base_total_paid) or SUM(base_total_paid), constructed using SQL_FUNCTIONS constant array and attribute value.
- second where() call filters orders by states set in APPLICABLE_ORDER_STATES (in our case – only ‘closed’ and ‘complete’ orders will be taken into account)
- before we filter our orders by time limit – we need to make sure it exists in the model. In our scenario, it’s supposed to be there (as we loaded it into a model in the parent ReturningCustomer condition), but one can imagine CustomerPurchaseHistory being directly injected into the top-level conditions’ array as a separate, stand-alone condition.
Finally, default the returned value to 0 – as we expect the number of purchases or purchases’ total to be a numeric value, and not null that may be returned by the query.