Cart Sales Rule

One of the features available by default in Magento is Sales Rule. The module is a powerful marketing tool that allows you to manage all types of discounts and promotions. You can find it in the Admin panel -> Marketing -> Cart Sales Rules. You can define many different options in which a given discount should be applied in the cart.

When you expand the “Conditions” tab, you’ll be able to set when a given discount applies. For example, you can select the cart value option and this way specify that:
If the cart value is greater than 50 – the discount should be applied.

However, the default options don’t cover all possible situations. Let’s imagine that we want to reward our client for the dedication he put in creating an account in our store. We have the following scenario:
If this is a first order of a given client – the discount should be applied.

Unfortunately, Magento doesn’t offer this option out-of-the-box – and this is where Magently comes to your rescue.

Custom Cart Sales Rule Conditions

In today’s article, we will focus on extending the available discount conditions. To understand how the available conditions are collected, you need to look into the Magento\SalesRule\Model\Rule\Condition\Combine class, more specifically into the getNewChildSelectOptions() method. You’ll notice that after the default conditions, the salesrule_rule_condition_combine event is dispatched and then the collected conditions are combined.

As usual, we will start with creating the foundations of our module:

//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">
    </module>
</config>
//file: app/code/Magently/CustomerRule/registration.php

<?php

\Magento\Framework\Component\ComponentRegistrar::register(
    \Magento\Framework\Component\ComponentRegistrar::MODULE,
    'Magently_CustomerRule',
    __DIR__
);

Next, we will create an observer that will add our condition:

//file: app/code/Magently/CustomerRule/etc/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\CustomerConditionObserver" />
    </event>
</config>
//file: app/code/Magently/CustomerRule/Observer/CustomerConditionObserver.php

<?php

namespace Magently\CustomerRule\Observer;

/**
 * Class CustomerConditionObserver
 */
class CustomerConditionObserver implements \Magento\Framework\Event\ObserverInterface
{
    /**
     * Execute observer.
     * @param \Magento\Framework\Event\Observer $observer
     * @return $this
     */
    public function execute(\Magento\Framework\Event\Observer $observer)
    {
        $additional = $observer->getAdditional();
        $conditions = (array) $additional->getConditions();

        $conditions = array_merge_recursive($conditions, [
            $this->getCustomerFirstOrderCondition()
        ]);

        $additional->setConditions($conditions);
        return $this;
    }

    /**
     * Get condition for customer first order.
     * @return array
     */
    private function getCustomerFirstOrderCondition()
    {
        return [
            'label'=> __('Customer first order'),
            'value'=> \Magently\CustomerRule\Model\Rule\Condition\Customer::class
        ];
    }
}

What happens in the observer? We’re fetching other conditions, for example the ones added in other observers, and merge them with ours. As you can see, one condition consists of a name and a value which means that it includes our class that will handle the condition.

Now, we can move on to the logic responsible for our condition:

//file: app/code/Magently/CustomerRule/etc/di.xml

<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
    <type name="Magently\CustomerRule\Model\Rule\Condition\Customer">
        <arguments>
            <argument name="data" xsi:type="array">
                <item name="form_name" xsi:type="string">sales_rule_form</item>
            </argument>
        </arguments>
    </type>
</config>
//file: app/code/Magently/CustomerRule/Model/Rule/Condition/Customer.php

<?php

namespace Magently\CustomerRule\Model\Rule\Condition;

/**
 * Class Customer
 */
class Customer extends \Magento\Rule\Model\Condition\AbstractCondition
{
    /**
     * @var \Magento\Config\Model\Config\Source\Yesno
     */
    protected $sourceYesno;

    /**
     * @var \Magento\Sales\Model\ResourceModel\Order\CollectionFactory
     */
    protected $orderFactory;

    /**
     * Constructor
     * @param \Magento\Rule\Model\Condition\Context $context
     * @param \Magento\Config\Model\Config\Source\Yesno $sourceYesno
     * @param \Magento\Sales\Model\ResourceModel\Order\CollectionFactory $orderFactory
     * @param array $data
     */
    public function __construct(
        \Magento\Rule\Model\Condition\Context $context,
        \Magento\Config\Model\Config\Source\Yesno $sourceYesno,
        \Magento\Sales\Model\ResourceModel\Order\CollectionFactory $orderFactory,
        array $data = []
    ) {
        parent::__construct($context, $data);
        $this->sourceYesno = $sourceYesno;
        $this->orderFactory = $orderFactory;
    }

    /**
     * Load attribute options
     * @return $this
     */
    public function loadAttributeOptions()
    {
        $this->setAttributeOption([
            'customer_first_order' => __('Customer first order')
        ]);
        return $this;
    }

    /**
     * Get input type
     * @return string
     */
    public function getInputType()
    {
        return 'select';
    }

    /**
     * Get value element type
     * @return string
     */
    public function getValueElementType()
    {
        return 'select';
    }

    /**
     * Get value select options
     * @return array|mixed
     */
    public function getValueSelectOptions()
    {
        if (!$this->hasData('value_select_options')) {
            $this->setData(
                'value_select_options',
                $this->sourceYesno->toOptionArray()
            );
        }
        return $this->getData('value_select_options');
    }

    /**
     * Validate Customer First Order Rule Condition
     * @param \Magento\Framework\Model\AbstractModel $model
     * @return bool
     */
    public function validate(\Magento\Framework\Model\AbstractModel $model)
    {
        $customerId = $model->getCustomerId();
        $order = $this->orderFactory->create()
            ->addAttributeToSelect('customer_id')
            ->addFieldToFilter('customer_id',['eq' => $customerId])
            ->getFirstItem();

        $firstOrder = 1;
        if ($order->getId()) {
            $firstOrder = 0;
        }
        $model->setData('customer_first_orde
r', $firstOrder);
        return parent::validate($model);
    }
}

We load the Yesno model in the constructor of our logic that is mainly used as a source_model in the backend. We’ll fetch from it the available values for our select fields. Moreover, we load the factory of order collection that we will use for validating the condition correctness. We need to set the name and label of our condition in the loadAttributeOptions() method. getInputType() defines what will be displayed as an operator for comparing our attribute – the returned select field will allow us to choose “is” or “is not”. Returning a numeric value here would allow you to select available operators for comparing numbers, such as “greater than” or “less than”. getValueElementType() defines the type of the value with which we will compare our values. The returned select will render the field with available options, which we will define in getValueSelectOptions().

In case we don’t want to define imposed values, we can return text – an input with the option to write a given value will then be displayed (using numeric value and text would allow you to create a condition “if the number of customer’s orders is greater than X – apply the discount”).

The last method is validate() that we use to check whether the customer has other orders placed with his account, and then we can set the value that will be compared with the one we previously defined.

Now, we only need to create a discount with our condition and… voilà!