Increasing your revenue with abandoned cart feature

Email marketing is a way to promote products or services through email. It is used as a top digital media channel, and it is important for customer acquisition and retention.

In this blog, we will build a mechanism that will collect data from an abandoned cart along with a visitor's email address, store it into a custom object, so that after a certain time we can use that data to notify our customers through email that they have uncompleted orders. We will achieve this with Salesforce Commerce Cloud, using some built-in functionalities in business manager and writing a custom code to support it.

Storing information about abandoned Carts

As we don't have any system object to store the data for all incomplete orders, we need to create our own custom object type. Following the path Administration > Site Development > Custom Object Types, we can find all the custom object types that are already created. To create a new one, we need to click New and provide an ID, which needs to be unique. A Key Attribute is also a unique identifier for the object type. From the dropdown menu, select a Storage Scope that determines whether it is assigned to a specific site or the entire organization. In the image below, you can see what we used for our case.

New custom object

 

After that, we need to go to the Attribute Definitions tab and add fields that will store information about a specific order.

 

Attribute

 

That information is the customer's email address, the cart's total price, an indication if the customer is a registered user or a guest, and a JSON object containing the cart's data before the customer decided to abandon it. We will achieve that by clicking on New and then defining the attribute by choosing a unique ID, a preferred Display Name, and an appropriate Value Type, and clicking Apply. In our case, for the customer’s email, the Value Type will be a String type, for totalPrice a Number, registeredCustomer Boolean, and for the JSON object a Text type. You can check out one example in the image below.

 

Attribute definition

 

To make it work, we also need to group the attributes that we created by going to the Attribute Grouping tab and creating a new Attribute Group by choosing an ID that is unique for this type and an arbitrary name. In our case, the ID will be ‘default’ and the name is ‘Default’, and after that, we click Add.

 

Attribute group

 

Now we need to assign our attributes to the newly created group. Clicking the Edit link, we will be redirected to the Assign Attribute Definition page where we need to click the three-dot button, and now we have a popup that contains all the attributes of this object type. In our case, we will choose our new attributes along with the type ID required to identify the object later in the code. Now, by clicking the Select button, we have assigned the attributes.

 

Grouping

 

In addition, we will add a new Site Preferences group so the feature can be configurable.

Firstly, we need to go to Administration -> System Object Types and search for SitePreferences. In the Site Preferences, we need to add two Attribute Definitions. AbandonedCartEnabled is a Boolean that tells us if the feature is enabled or not, and abandonedCartEmail is the email address from where we will be sending the objects. After that, we need to go to Attribute Grouping, create a new group called Abandoned Cart and add all three attributes to it. Now, we will need to set the values for the attributes by going to Merchant Tools->Custom Preferences. Find Abandoned Cart and add the values you want to the two fields as shown in the image below. After all these steps are done successfully, we have our custom object type and site preferences created in the business manager, and now we need to do some coding to implement it on our site.

 

Site preferences

 

After all these steps are done successfully, we have our custom object type and site preferences created in the business manager, and now we need to do some coding to implement it on our site.

 

Creating and Handling Custom Objects

 

Firstly, we will create a helper with functions for handling the objects that will be used later in the code.

The createNewObject function is used to initially create the custom objects and store them in the database. Start with mapping fields of the product that are important for restoring the basket afterwards, followed by creating a unique ID, which is created by merging the basket UUID and Date.now() timestamp. Now, by calling createCustomObject from customObectjMgr and passing the name of the object as the first parameter and the ID as the second, it will create it and store it in the custom object. Now we need to fill the object fields, and to do that, we will wrap it in a Transaction so it can be saved to the DB. Besides that, we will need to store the basket UUID and the abandoned cart ID to the session that will be used to make sure that we already created an object for the session and prevent it from making another one each time we enter the checkout process.

For deleteObject we will just need the ID of the custom object, and by calling the remove() function and passing that ID, it will delete the object. Also, we need to delete the previously stored data from the session.

UpdateCartInfo will be used when adding, removing and updating lineItems on the basket level. For that one, we will again just need the ID of the custom object to get it with the getCustomObject function, and similar to createNewObject, we will map the product and update the object by wrapping it all into a transaction.

Similar to updating the cart, we will create the updateEmail function that will be used if a guest user changes it at the beginning of the checkout process.

const CustomObjectMgr = require('dw/object/CustomObjectMgr');
const Transaction = require('dw/system/Transaction');
const Logger = require('dw/system/Logger');
const Collections = require('*/cartridge/scripts/util/collections');
const BasketMgr = require('dw/order/BasketMgr');
 
function createNewObject(email, currentBasket) {
  const products = Collections.map(currentBasket.allProductLineItems, function (item) {
    return {
      productID: item.productID,
      color: item.product.custom.refinementColor.displayValue,
      price: item.priceValue,
      size: item.product.custom.size,
      name: item.productName,
      quantity: item.quantityValue
    }
  });
  const ID = currentBasket.UUID + '_' + Date.now();
 
  try {
    Transaction.wrap(function () {
      let co = CustomObjectMgr.createCustomObject('abandonedCart', ID);
      co.custom.customerEmail = email;
      co.custom.cartInfo = JSON.stringify(products, null, 4);
      co.custom.registeredCustomer = customer.registered;
      co.custom.totalPrice = currentBasket.adjustedMerchandizeTotalPrice.value;
    });
    session.custom.lastBasketID = currentBasket.UUID;
    session.custom.abandonedCartId = ID;
  } catch (e) {
    Logger.error('{0}', e.message);
  }
}
 
function deleteObject(ID) {
  try {
    Transaction.wrap(function () {
      const co = CustomObjectMgr.getCustomObject('abandonedCart', ID);
      CustomObjectMgr.remove(co);
    });
    delete session.custom.lastBasketID;
    delete session.custom.abandonedCartId;
  } catch (e) {
    Logger.error('{0}', e.message);
  }
}
 
function updateCartInfo(ID) {
  const currentBasket = BasketMgr.getCurrentBasket();
 
  const products = Collections.map(currentBasket.allProductLineItems, function (item) {
    return {
      productID: item.productID,
      color: item.product.custom.refinementColor.displayValue,
      price: item.priceValue,
      size: item.product.custom.size,
      name: item.productName,
      quantity: item.quantityValue
    }
});
 
  try {
    Transaction.wrap(function () {
      let co = CustomObjectMgr.getCustomObject('abandonedCart', ID);
      co.custom.cartInfo = JSON.stringify(products, null, 4);
      co.custom.totalPrice = currentBasket.adjustedMerchandizeTotalPrice.value;
    });
  } catch (e) {
    Logger.error('{0}', e.message);
  }
}
 
function updateEmail(ID, newEmail) {
  try {
    Transaction.wrap(function () {
      const co = CustomObjectMgr.getCustomObject('abandonedCart', ID);
      co.custom.customerEmail = newEmail;
    });
  } catch (e) {
    Logger.error('{0}', e.message);
  }
}
 
module.exports = {
  createNewObject: createNewObject,
  deleteObject: deleteObject,
  updateCartInfo: updateCartInfo,
  updateEmail: updateEmail
};

For this example, the object will be created in two cases, one for guest customers as soon as we know their email addresses, and one for logged customers at the moment of creating the basket.

To cover the guest customer scenario, we need to extend the CheckoutServices.js controller from the base cartridge by using the server.extend function with a module.superModule parameter and appending the 'SubmitCustomer' endpoint.

After we check if the feature is enabled in Site Preferences, we will check if the custom object is already created by looking into the data from the session and update the email to make sure it is the latest one. Now, if the basket is available, we will use our custom function createNewObject to create our custom object.

server.append('SubmitCustomer', server.middleware.https, function (req, res, next) {
  if (Site.current.getCustomPreferenceValue('abandonedCartEnabled')) {
    const BasketMgr = require('dw/order/BasketMgr');
    const currentBasket = BasketMgr.getCurrentBasket();
 
    if (session.custom.lastBasketID === currentBasket.UUID) {
      AbandonedCartHelpers.updateEmail(session.custom.abandonedCartId, res.viewData.customer.email.value);
      return next();
    }
    if (currentBasket) AbandonedCartHelpers.createNewObject(res.viewData.customer.email.value, currentBasket);
  }
 
  next();
});

Now, as we covered guest users, we need to cover the second case, and that is the registered customer. As we know the customer’s email right away, we will create the object as soon as they add a product to the basket. To achieve that, we will need to extend the AddProduct, RemoveProductLineItem and UpdateQuantity endpoint. As the helpers are already created, the logic is pretty straightforward for all endpoints. So, basically again making sure that the feature is enabled in Site Preferences, that the customer logged in, and if we have the current basket already saved in the session, we can decide whether we should call createNewObject or updateCartInfo. We have just one specific case here and that is when removing a product from the cart, we need to check if the basket is empty, in which case we will call the deleteObject function to delete the whole custom object.

server.append('AddProduct', function (req, res, next) {
  if (res.viewData.error) {
    return next();
  }
 
  if (Site.current.getCustomPreferenceValue('abandonedCartEnabled')) {
    const BasketMgr = require('dw/order/BasketMgr');
    const currentBasket = BasketMgr.getCurrentBasket();
 
    if (customer.registered && currentBasket && session.custom.lastBasketID !== currentBasket.UUID) {
      AbandonedCartHelpers.createNewObject(customer.profile.email, currentBasket);
      return next();
    }
 
    if (session.custom.abandonedCartId) {
      AbandonedCartHelpers.updateCartInfo(session.custom.abandonedCartId);
    }
  }
  next();
});
 
server.append('RemoveProductLineItem', function (req, res, next) {
  if (Site.current.getCustomPreferenceValue('abandonedCartEnabled') && session.custom.abandonedCartId && res.viewData.basket) {
    if (res.viewData.basket.numItems === 0) {
      AbandonedCartHelpers.deleteObject(session.custom.abandonedCartId);
      return next();
    }
 
    AbandonedCartHelpers.updateCartInfo(session.custom.abandonedCartId);
  }
  next();
});

server.append('UpdateQuantity', function (req, res, next) {
  if (Site.current.getCustomPreferenceValue('abandonedCartEnabled') && session.custom.abandonedCartId && !res.viewData.valid.error) {
    AbandonedCartHelpers.updateCartInfo(session.custom.abandonedCartId);
  }
  next();
});

The email is sent only if the customer has abandoned the cart, so we need to make sure to delete the object if the customer actually places the order. So, we will now append the PlaceOrder endpoint the same way as we did with the last one using another custom-made function.

server.append('PlaceOrder', server.middleware.https, function (req, res, next) {
  if (Site.current.getCustomPreferenceValue('abandonedCartEnabled') && !res.viewData.error) {
    AbandonedCartHelpers.deleteObject(session.custom.abandonedCartId);
  }
  next();
});

All custom objects that we created can be found by going to site > Custom Objects > Custom Object Editor, finding our object type name from the list, and hitting the find button. It should be noted that on this page, with the right permissions, we can edit, add and remove our object manually, but these functionalities should be used just for testing purposes while implementing the code.

 

Objects in BM

 

One thing we need to keep in mind is that we have limits set for the number of created object types to 300 and a total of 400,000 custom objects, with a warning at 240,000.

Now that we have the procedure to save all the necessary data, we can create a job that runs once at a time, fetches the objects one by one, and sends an email with the cart content to every customer for which we have created an object 15 days before the job executed, and after the email is successfully sent, deletes the custom object to make sure we don't exceed the limit we mentioned above. It is also recommended to set a retention on the custom object itself so we prevent sending really old abandoned carts to customers.

 

Creating a job

A good practice when making some integrations is to create a new cartridge and name it with a prefix int as I have done in this example. Now, the first thing that needs to be done is to configure a step type by creating a JSON file as shown below.

"step-types": {
    "script-module-step": [
      {
        "@type-id": "custom.HandlingEmailsAbandonedCart",
        "@supports-parallel-execution": "true",
        "@supports-site-context": "true",
        "@supports-organization-context": "false",
        "description": "Send an email about abandoned cart to customers",
        "module": "int_practice/cartridge/scripts/steps/HandleEmailsAbandonedCart.js",
        "function": "HandleEmails",
        "timeout-in-seconds": "900",
        "parameters": {
          "parameter": [
            {
              "@name": "days",
              "@type": "string",
              "description": "Number of days that passed since the cart object was created",
              "@required": "true"
            }
          ]
        },
        "status-codes": {
          "status": [
            {
              "@code": "ERROR",
              "description": "Used when the step failed with an error."
            },
            {
              "@code": "OK",
              "description": "Used when the step executed ok."
            }
          ]
        }
      }
    ]
  }
}

After that, we want to create a script file on the path which is given in the module attribute in steptypes.json with a function HandleEmails, which is also given in that JSON file. First of all, we call the getAllCustomObjects to get all the custom objects and create an email object. As the objects we got are a seekableIterator class, we need to iterate through it with a while loop and process the object individually by setting the email and cart content from the custom object to our new object and sending the email using the already created email helper script, and then delete the custom object. The URL will be defined depending on whether the user is registered or not. If the user is not registered, we will redirect them directly to the Checkout-Begin and pass the custom object ID to restore the basket right away, but if they are registered, we will redirect them to our custom made endpoint Cart-AbandeonedCartRegistered.

const Status = require('dw/system/Status');
const Logger = require('dw/system/Logger');
const CustomObjectMgr = require('dw/object/CustomObjectMgr');
const ProductMgr = require('dw/catalog/ProductMgr');
const URLUtils = require('dw/web/URLUtils');
const EmailHelpers = require('*/cartridge/scripts/helpers/emailHelpers');
const Site = require('dw/system/Site');
const Resource = require('dw/web/Resource');
 
function HandleEmails(parameters) {
  try {
    const date = new Date();
    date.setDate(date.getDate() - parameters.days);
    const customObjects = CustomObjectMgr.queryCustomObjects("abandonedCart", "creationDate <= {0}", null, date);
    const emailObj = {
      subject: Resource.msg('abandonedcart.email.subject', 'abandonedCart', null),
      from: Site.getCurrent().getCustomPreferenceValue('customerServiceEmail'),
      type: EmailHelpers.emailTypes.abandonedCart
    };
    const onlyAvailable = parameters.onlyAvailable;
   
    while (customObjects.hasNext()) {
      let co = customObjects.next();
      let cart = JSON.parse(co.custom.cartInfo);
      let totalPrice = 0;
      const objectForEmail = {
        cart: onlyAvailable ? cart.filter(function (item) {
          const product = ProductMgr.getProduct(item.productID);
          if (product && product.getAvailabilityModel().isInStock()) {
            totalPrice += item.price;
            return item;
          }
        }) : cart,
        totalPrice: onlyAvailable ? totalPrice : co.custom.totalPrice.toString(),
        url: co.custom.registeredCustomer ?
        URLUtils.https('Cart-AbandonedCartRegistered', 'abanodenCartObjectId', co.custom.abandonedCartId) :
        URLUtils.https('Checkout-Begin', 'abanodenCartObjectId', co.custom.abandonedCartId)
      };
      emailObj.to = co.custom.customerEmail;
      if (!empty(objectForEmail.cart)) EmailHelpers.sendEmail(emailObj, 'account/cart/abandonedCart', objectForEmail);
    }
  } catch (e) {
    Logger.error('{0}', e.message);
    return new Status(Status.ERROR, "ERROR", "Emails are not sent!");
  }
  return new Status(Status.OK, "OK", "Emails sent successfully");
}
 
module.exports = {
  HandleEmails: HandleEmails
};

Also, we will add the email subject to properties, so it can be changed to other languages afterwards and to avoid hardcoding values.

abandonedcart.email.subject=You forgot something ?

We also need to extend the emailHelpers.js helper with our new “abandonedCart” type and create an email template on the path which we passed to the sendEmail function. In our example, we created a simple template that loops all the products from the cart and just prints the primary info.

module.exports = Object.assign({}, module.superModule, {
  emailTypes: {
    registration: 1,
    passwordReset: 2,
    passwordChanged: 3,
    orderConfirmation: 4,
    accountLocked: 5,
    accountEdited: 6,
    abandonedCart: 7
  }
});
<body>
  <h1> You forgot something ? </h1>
  <isloop items="${pdict.cart}" var="product">
    <span><strong>${product.name}</strong></span>
 
    <p> Color : ${product.color}</p>
    <p> Size : ${product.size}</p>
    <p> Qty : ${product.quantity}</p>
    <p> Price : ${product.price}</p>
    <hr />
  </isloop>
  <p>Total Price: ${pdict.totalPrice}</p>
  <a href="${pdict.url}">Click here to continue to cart</a>
</body>

The last thing we need to do is create the job in business manager by going to Administration -> Jobs, click New Job, give it a unique ID and click Create.

 

new job

 

When the job is created, we will go to the Job Steps tab, click Configure a step, and search for our job step which is given in the steptypes.json file in the type-id attribute.

 

Job ex

 

Restoring the basket for the customer

Now, to be able to access the URL that we forwarded to the email for registered customers, we need to create a new endpoint by extending the Cart.js controller. As you can see from the code below, we need to check if the customer is logged in, and if that is not the case, we will redirect them to the Login-Show endpoint and set the abandonedCartLogin attribute to true. If the customer is in fact logged in, we will redirect them to Checkout-Begin passing the custom object ID.

server.get('AbandonedCartRegistered', server.middleware.https, function (req, res, next) {
  const objectId = req.querystring.objectId;
 
  if (!customer.registered) {
    session.custom.customObjectId = objectId;
    res.redirect(URLUtils.url('Login-Show', 'abandonedCartLogin', 'true'));
    return next();
  }
  res.redirect(URLUtils.url('Checkout-Begin', 'abanodenCartObjectId', objectId)
);
  return next();
});

Extending the Show endpoint of the Account controller will allow us to restore the basket after the customer is logged in by using the previously saved custom object ID and passing it to our restoreBasket function, after which we delete it from the session.

server.append('Show',
  server.middleware.https,
  userLoggedIn.validateLoggedIn,
  consentTracking.consent,
  function (req, res, next) {
 
    const URLUtils = require('dw/web/URLUtils');
    const target = req.querystring.rurl || 1;
    if (req.querystring.abandonedCartLogin) {
        res.setViewData({
            actionUrl: URLUtils.url('Account-Login', 'rurl', target),
            abandonedCartLogin : req.querystring.abandonedCartLogin,
            customObjectId: session.custom.customObjectId
        });
        return next();
    }
 
    next();
  });

As we have a case for guest or logged users where we redirect them directly to Checkout-Begin, we need to extend that one also where we just check if the abandonedCartObjectId is passed, and if yes, we just restore the basket using the data from the object.

server.prepend('Begin', server.middleware.https, consentTracking.consent, csrfProtection.generateToken, function (req, res, next) {
    const objectId = req.querystring.abanodenCartObjectId;
    if (!empty(objectId)) {
      CartHelpers.restoreBasket(objectId);
      return next();
    }
    next();
});

As we already have a cart helper defined in the base cartridge, we will not make a new one, but we will extend that one. Firstly, we will create our custom object from the ID that we receive as a parameter and then add the products one by one to the basket with the already defined function. If some products aren’t available at the moment of addition, they will be ignored as that is already implemented in the cartHelper.

const assign = require('server/assign');
const BasketMgr = require('dw/order/BasketMgr');
const Transaction = require('dw/system/Transaction');
const CustomObjectMgr = require('dw/object/CustomObjectMgr');
 
function restoreBasket(customObjectId) {
  const currentBasket = BasketMgr.getCurrentBasket();
  const object = CustomObjectMgr.getCustomObject("abandonedCart", customObjectId);
  if (currentBasket && object) {
    const cart = JSON.parse(object.custom.cartInfo);
    cart.forEach(function (item) {
      Transaction.wrap(function () {
        module.superModule.addProductToCart(
          currentBasket,
          item.productID,
          item.quantity,
          [],
          []
        );
      });
    })
  }
}
 
module.exports = assign(module.superModule, {
  restoreBasket: restoreBasket
});

WRAP UP

That is all you need to do to make this functionality available for your website as well. It is always important to see that even small things can add more value to your business. We didn’t go too deep into details and just covered the basic steps to also show you how to create and handle custom objects and jobs, so I'm sure you will think of more ways this can be improved and be more tailor-made for your exact use case.