Allow backorders on a website scope

We’re all aware of the fact that Magento handles inventory of products in a (fairly) straightforward fashion. There is only one “warehouse”, one inventory, one “number” in the database that is responsible for a final say – how much of it is in stock. A lot of other functionality is dependent on the fact that stock is global. If we check “Advanced inventory” configuration from the administration of a product, we notice that all of the options are global: Out of stock threshold, Minimum and maximum qty allowed in Shopping Cart, backorders, notifications etc.

While this is perfectly normal – having a global quantity (stock), requires most of the functionality related to it, to have global setting as well, a situation may arise, in which we need some of the functionality to work on a different scope (e.g. website or store view scope). Example of this would be backordering. A simple functionality – allow customer to purchase something even if it’s not in stock. This decreases quantity further into negative values, e.g. -5.

Can we extend this functionality beyond just global scope, and have it work on, say, website level? It turns out we can, and it’s not that big of a change.

To investigate this first, we’d like to add to cart more than we have. By doing this, we end up in (omitting a few steps for brevity) vendor/magento/module-catalog-inventory/Model/StockStateProvider.php

/**
     * Check quantity
     *
     * @param StockItemInterface $stockItem
     * @param int|float $qty
     * @exception MagentoFrameworkExceptionLocalizedException
     * @return bool
     */
    public function checkQty(StockItemInterface $stockItem, $qty)
    {
        if (!$this->qtyCheckApplicable) {
            return true;
        }
        if (!$stockItem->getManageStock()) {
            return true;
        }
        if ($stockItem->getQty() - $stockItem->getMinQty() - $qty < 0) {
            switch ($stockItem->getBackorders()) {
                case MagentoCatalogInventoryModelStock::BACKORDERS_YES_NONOTIFY:
                case MagentoCatalogInventoryModelStock::BACKORDERS_YES_NOTIFY:
                    break;
                default:
                    return false;
            }
        }
        return true;
    }

What this does is check if qty we want to purchase is available. If it’s not, it performs a checks for the backorder functionality, which leads us further, to vendor/magento/module-catalog-inventory/Model/Configuration.php for retrieving the backorder information from database (core_config_data).

/**
     * Retrieve backorders status
     *
     * @param null|string|bool|int|MagentoStoreModelStore $store
     * @return int
     */
    public function getBackorders($store = null)
    {
        return (int) $this->scopeConfig->getValue(
            self::XML_PATH_BACKORDERS,
            MagentoStoreModelScopeInterface::SCOPE_STORE,
            $store
        );
    }

Interesting thing to notice is the $store variable which is passed when fetching the configuration details. So, Magento is looking for a scoped value of backorder setting in database, but since there is none, a global is returned. What if we change scope for this configuration from global to website? Let’s try.

  1. Make a simple Magento 2 module (like this one, for example).
  2. Create a file at appcodeYour_VendorYour_Moduleetcadminhtmlsystem.xml

with the following content:

<?xml version="1.0"?>
<!--
/**
 * Copyright © 2013-2017 Magento, Inc. All rights reserved.
 * See COPYING.txt for license details.
 */
-->
<config xmlns_xsi="http://www.w3.org/2001/XMLSchema-instance" xsi_noNamespaceSchemaLocation="urn:magento:module:Magento_Config:etc/system_file.xsd">
    <system>
        <section id="cataloginventory" translate="label" type="text" sortOrder="50" showInDefault="1" showInWebsite="1" showInStore="1">
            <group id="item_options" translate="label comment" type="text" sortOrder="10" showInDefault="1" showInWebsite="1" showInStore="1">
                <field id="backorders" translate="label" type="select" sortOrder="3" showInDefault="1" showInWebsite="1" showInStore="0" canRestore="1">
                    <label>Backorders</label>
                    <source_model>MagentoCatalogInventoryModelSourceBackorders</source_model>
                    <backend_model>MagentoCatalogInventoryModelConfigBackendBackorders</backend_model>
                    <comment>Changing can take some time due to processing whole catalog.</comment>
                </field>
            </group>
        </section>
    </system>
</config>

We’ve just copied over core configuration for backorders located at vendor/magento/module-catalog-inventory/etc/adminhtml/system.xml and updated showInWebsite from 0 to 1. Now we have configuration for backorders on a website level.

Couple of implications arise from this change:

  1. obviously, we can set backorder functionality on a website level
  2. since there is still only 1 inventory, we need to make sure that default scope for backorders is turned on, since cataloginventory_stockstatus indexer uses this to properly set status
  3. if default config is enabled (as point 2 suggests), then all products would be “Available” on frontend, which is not something we want for <b>all</b> websites (“Add to cart” button would be visible everywhere)

Thankfully, Magento offers a handy events which we can observe in order to “fix” stock status for website that does not have bacorders available.

vendor/magento/module-catalog/Model/Product.php has isSalable() method, called from frontend (when deciding whether add-to-cart button should be displayed). It is dispatching catalog_product_is_salable_before and catalog_product_is_salable_after which we can use, and display products as “not salable” if they are in the store that does not provide backorder functionality.

/**
     * Check is product available for sale
     *
     * @return bool
     */
    public function isSalable()
    {
        // ...
 
        <strong>$this->_eventManager->dispatch('catalog_product_is_salable_before', ['product' => $this]);</strong>
 
        $salable = $this->isAvailable();
 
        $object = new MagentoFrameworkDataObject(['product' => $this, 'is_salable' => $salable]);
 
        <strong>$this->_eventManager->dispatch(
            'catalog_product_is_salable_after',
            ['product' => $this, 'salable' => $object]
        );</strong>
 
        // ...
    }

What we can do here is create a custom observer that would listen to one of these events, and set product as not salable ($product->setIsSalable(false)) when a qty is <= 0 and store is the one where we don’t have backorders.

That’s it – a small change in the configuration, and a custom observer to make sure all data is properly displayed, gives us the ability to have backorder functionality on a website scope.