Sitecore Experience Commerce: Promotion Evaluation and Application Logic

In this article, we will review the default business logic that the Commerce Engine utilises to evaluate and apply promotions.

Note: References to date will indicate both date and time throughout this article.

Introduction

Before we get into the details around promotions, there are a few things we need to understand.

    • Promotions are separated into cart line level and cart level promotions, determined by the promotion benefits configured to each promotion. While multiple benefits can be added to promotions, additional benefits after the first can only be of the same benefit type.
    • Cart line calculations (subtotals, fulfillment fees, promotion discounts, taxes, totals) are evaluated and applied prior to the and cart calculations.
      Sitecore.Commerce.Plugin.Carts
      ICalculateCartLinesPipeline (Sitecore.Commerce.Plugin.Carts.Cart => Sitecore.Commerce.Plugin.Carts.Cart)
           ------------------------------------------------------------
           Plugin.Carts.ClearCartLinesBlock (Sitecore.Commerce.Plugin.Carts.Cart => Sitecore.Commerce.Plugin.Carts.Cart)
           ------------------------------------------------------------
           Plugin.Carts.ClearCartBlock (Sitecore.Commerce.Plugin.Carts.Cart => Sitecore.Commerce.Plugin.Carts.Cart)
           ------------------------------------------------------------
           Plugin.Carts.CalculateCartLinesSubTotalsBlock (Sitecore.Commerce.Plugin.Carts.Cart => Sitecore.Commerce.Plugin.Carts.Cart)
           ------------------------------------------------------------
           Plugin.Fulfillment.CalculateCartLinesFulfillmentBlock (Sitecore.Commerce.Plugin.Carts.Cart => Sitecore.Commerce.Plugin.Carts.Cart)
           ------------------------------------------------------------
           Plugin.Promotions.CalculateCartLinesPromotionsBlock (Sitecore.Commerce.Plugin.Carts.Cart => Sitecore.Commerce.Plugin.Carts.Cart)
           ------------------------------------------------------------
           Plugin.Tax.CalculateCartLinesTaxBlock (Sitecore.Commerce.Plugin.Carts.Cart => Sitecore.Commerce.Plugin.Carts.Cart)
           ------------------------------------------------------------
           Plugin.Carts.CalculateCartLinesTotalsBlock (Sitecore.Commerce.Plugin.Carts.Cart => Sitecore.Commerce.Plugin.Carts.Cart)
      -----------------------------------------------------------------
      Sitecore.Commerce.Plugin.Carts
      ICalculateCartPipeline (Sitecore.Commerce.Plugin.Carts.Cart => Sitecore.Commerce.Plugin.Carts.Cart)
           ------------------------------------------------------------
           Plugin.Carts.CalculateCartSubTotalsBlock (Sitecore.Commerce.Plugin.Carts.Cart => Sitecore.Commerce.Plugin.Carts.Cart)
           ------------------------------------------------------------
           Plugin.Fulfillment.CalculateCartFulfillmentBlock (Sitecore.Commerce.Plugin.Carts.Cart => Sitecore.Commerce.Plugin.Carts.Cart)
           ------------------------------------------------------------
           Plugin.Promotions.CalculateCartPromotionsBlock (Sitecore.Commerce.Plugin.Carts.Cart => Sitecore.Commerce.Plugin.Carts.Cart)
           ------------------------------------------------------------
           Plugin.Tax.CalculateCartTaxBlock (Sitecore.Commerce.Plugin.Carts.Cart => Sitecore.Commerce.Plugin.Carts.Cart)
           ------------------------------------------------------------
           Plugin.Carts.CalculateCartTotalsBlock (Sitecore.Commerce.Plugin.Carts.Cart => Sitecore.Commerce.Plugin.Carts.Cart)
           ------------------------------------------------------------
           Plugin.Payments.CalculateCartPaymentsBlock (Sitecore.Commerce.Plugin.Carts.Cart => Sitecore.Commerce.Plugin.Carts.Cart)
           ------------------------------------------------------------
           Plugin.Carts.WriteCartTotalsToContextBlock (Sitecore.Commerce.Plugin.Carts.Cart => Sitecore.Commerce.Plugin.Carts.Cart)
      
    • Exclusive promotions apply against the benefit type only, therefore it is possible to apply a cart line level exclusive promotion and a cart level exclusive promotion at the same time.

Evaluating Promotions

The following diagram shows the pipelines and pipeline blocks that are called during the process of evaluating the applicable promotions and additional filtering for exclusive promotion evaluation.

There are essentially 10 steps that make up the evaluation process:

  1. Search For Promotions: Retrieves all promotions.
  2. Filter Promotions By Valid Date: Removes promotions that do not fall within the Valid From/To dates based on effective date.
  3. Filter Not Approved Promotions: Removes promotions that are not approved and, if the promotion is disabled, where the effective date is prior to the updated date (the date the promotion was disabled). The latter rule is to allow the promotion to be active when reviewed the storefront at a previous point in time.
    Note: From 9.0.1, the GlobalPromotionsPolicy was introduced to allow promotions to be previewed in the storefront prior to submitting a promotion for approval.
  4. Filter Promotions By Items: Removes promotions where the cart contains no sellable items marked as included in the ItemsCollection qualification or where the cart contains any of the sellable items marked as excluded in the ItemsCollection qualification.
  5. Filter Promotions By Book Associated Catalogs: Removes promotions where the catalog, associated to its promotion book, does not match any of the catalogs associated to the sellable items of the cart lines.
  6. Filter Promotions By Benefit Type: Removes promotions where the type of benefits configured to the promotion does not match the benefit type being evaluated. i.e. Cart line level benefits (CartLineActions) and cart level benefits (CartActions) for CalculateCartLinesPipeline and CalculateCartLinesPipeline respectively.
  7. Filter Promotions By Coupon: Removes promotions that require a coupon that has not been applied to the cart.
  8. Evaluate Promotions: Filters out promotions where promotion qualifications and benefit rules do are not applicable to the current cart.
  9. Filter Promotions With Coupons By Exclusivity: If exclusive coupon promotions are present in the promotions list, the list will be filtered down to a single exclusive coupon promotion. The promotion will be determined by the Added date that their corresponding coupons were applied to the cart.
  10. Filter Promotions By Exclusivity: If exclusive automatic promotions are present in the promotions list, the list will be filtered down to a single exclusive automatic promotion. The promotion will be determined by the earliest (oldest) Valid From date, and in the event of multiple promotions sharing the same earliest Valid From date the promotion that was created earliest will take win.

Promotion Priorisation Rules

While the previous section covered how promotions are evaluated, and also provided some insight into promotion priorisation, we will now cover the prioritisation rules.

The following diagram shows the logic used to determine which promotion(s) to apply to the cart.

There are essentially 3 steps that make up the application process:

  1. Apply a single exclusive automatic promotion.
    • The promotion will be determined by the earliest (oldest) Valid From date, and in the event of multiple promotions sharing the same earliest Valid From date the promotion that was created earliest will take win.
    • If a promotion is applied here no further promotions are applied.
  2. Apply a single exclusive coupon promotion.
    • The promotion will be determined by the Added date that their corresponding coupons were applied to the cart.
    • If a promotion is applied here no further promotions are applied.
  3. Apply all non-exclusive promotions.
    • The promotion order will be determined by:
      1. Automatic promotions ordered by earliest (oldest) Valid From date, and in the event of multiple promotions sharing the same earliest Valid From date the promotion that was created earliest will take win.
      2. Coupon Promotions ordered by earliest Added date that their corresponding coupons were applied to the cart.

References

Sitecore Experience Commerce: Configuring Debug Logging

In this article, we will look into the steps necessary in order to reduce the log level to Debug in the Retail Demo for Sitecore Commerce 8.2.1.

In LoggingExtentions.cs, the MinimumLevel is set to Information, therefore regardless what the Logging.LogLevel.Default and Serilog.MinimumLevel configurations in config.json are set to Debug log requests will not be logged.

var loggingConfig = new LoggerConfiguration()
		.Enrich.With(new ScLogEnricher())
		.MinimumLevel.Information();

By updating this line to MinimumLevel.Debug() , we are now able to drop the log level configurations to Debug and see the output in the log file.

"Logging": {
	"LogLevel": {
		"Default": "Debug"
	},
},
"Serilog": {
	"MinimumLevel": "Debug"
}
1 14:01:53 INFO Registering Pipeline Block 'Project.Commerce.Engine.Pipelines.Blocks.ValidateSitecoreConnectionBlock'
3 14:01:53 DEBUG [PipelineStarted]
3 14:01:53 DEBUG Block execution started for '"pipelines:blocks:StartNodeBlock"'
15 14:01:53 INFO [NodeStartup] ContactId='Deployment01_a1adc4875f9a4ef6b9513d9379373d2c',Environment='GlobalEnvironment'
15 14:01:53 DEBUG Block execution completed for '"pipelines:blocks:StartNodeBlock"'
15 14:01:53 DEBUG Block execution started for '"pipelines:blocks:StartNodeLogConfigurationBlock"'
15 14:01:56 WARN Performance Counter Category 'SitecoreCommerceCommands-1.0.2' not found
15 14:01:56 WARN Performance Counter Category 'SitecoreCommerceMetrics-1.0.2' not found
15 14:01:56 DEBUG Block execution completed for '"pipelines:blocks:StartNodeLogConfigurationBlock"'
15 14:01:56 DEBUG [PipelineCompleted]

Note: It is not recommended that Debug log levels and set in the production environment and when using Debug log levels in any environment, it is recommended to only utilise this log level for the smallest window necessary to avoid excessive logging as this will eat away at the storage space quite quickly.

Commerce Controller Types in Sitecore Commerce

In this article, we review the Commerce Controller types to identify the purpose behind each controller type and assist with solution design in order to implement Commerce Controllers correctly in your Commerce Engine Plugin projects.

Entity Controllers

Entity Controllers, e.g. CartsController, implement Get methods and should only retrieve data, returning specific entitieslists of entities, or managed lists of entities. They should not manipulate data in the commerce databases or trigger behaviour within the Commerce Engine.

The following code snippet shows the Get Carts endpoint in the CartsController from the Carts Plugin.

public async Task<IEnumerable<Cart>> Get()
{
	CommerceList<Cart> commerceList = await Command<FindEntitiesInListCommand>()?.Process<Cart>(CurrentContext, CommerceEntity.ListName<Cart>(), 0, int.MaxValue);

	return (commerceList?.Items.ToList()) ?? new List<Cart>();
}

Api and CommerceOps Controllers

Api and CommerceOps Controllers implement methods that return non-OData entities only. The only difference between the two controllers is the Api and CommerceOps controller methods are routed via the ‘/api’ and /commerceops’ URL segments respectively.

The following code snippet shows the GetBulkPrices endpoint in the ApiController from the Catalog plugin.

public async Task<IActionResult> GetBulkPrices([FromBody] ODataActionParameters value)
{
	if (!ModelState.IsValid)
		return new BadRequestObjectResult(ModelState);
	if (!value.ContainsKey("itemIds") || !(value["itemIds"] is JArray))
		return new BadRequestObjectResult(value);

	var jarray = (JArray)value["itemIds"];
	IEnumerable<SellableItemPricing> bulkPrices = await Command<GetBulkPricesCommand>().Process(CurrentContext, jarray?.ToObject<IEnumerable<string>>());

	return new ObjectResult(bulkPrices);
}

Commands Controllers

Commands Controllers implement OData actions for manipulating data through Commands. They should only return Command OData entities, not commerce entities like carts. 

The command object contains information about the execution of the command, i.e. ResponseCodeStatusisCancelledisCompleted, etc.

Some APIs do not wait for the command to complete and will return the command with the Status property as WaitingForActivation. These long running commands can be followed up on using the CheckCommandStatus() API with taskId parameter. See the Check Long Running Command Status API in the Sitecore DevOps postman collection.

The following code snippet shows the AddFederatedPayment endpoint in the CommandsController from the Payments plugin.

public async Task<IActionResult> AddFederatedPayment([FromBody] ODataActionParameters value)
{
	if (!ModelState.IsValid || value == null)
		return new BadRequestObjectResult(ModelState);
	if (!value.ContainsKey("cartId") || (string.IsNullOrEmpty(value["cartId"]?.ToString()) || !value.ContainsKey("payment")) || string.IsNullOrEmpty(value["payment"]?.ToString()))
		return new BadRequestObjectResult(value);
	
	var cartId = value["cartId"].ToString();
	var paymentComponent = JsonConvert.DeserializeObject<FederatedPaymentComponent>;(value["payment"].ToString());
	var command = Command<AddPaymentsCommand>();
	
	await command.Process(CurrentContext, cartId, new List<PaymentComponent>() { paymentComponent });
	
	return new ObjectResult(command);
}

Summary

While there’s nothing preventing developers from incorrectly using the Commerce Controllers, these coding standards will contribute to the long term maintainability of your Sitecore Commerce solutions.

Configuring Customers for Multi-Commerce-Site Solutions

Overview

In this post I will be taking you through the configuration and implementation required to enable separate customer accounts per site in a multi-commerce-site solution.

Note: We won’t go through the process of adding a second commerce storefront in this post.

Understanding Customer Accounts

Let us first understand how customer accounts are associated to a site using the Sitecore Retail Demo as our reference solution.

First of all, we review the Sitecore configurations, via http://retail.dev.local/sitecore/admin/showconfig.aspx, for pipelines that create users.  We see that the commerce.customers.createUser pipeline employs the SitecoreUserRespository, which handles all CRUD operations for users. Notably, the domain parameter of the repository, ‘CommerceUsers’ , is utilised to construct the user’s name in the format <domain>\<website user name>.

This is great news!

We have just identified that customer accounts are registered with a domain, so our next step is to identify how to configure the domain per site, so that users don’t share the same account and login details across sites in a multi-site solution.

Now, we could update the domain on the repository configuration, but this only solves the issue of updating the domain in a single domain implementation. We need to investigate the ‘CommerceUsers’ domain further.

Further Investigation and Implementation

Looking back into the Sitecore configurations, we want to identify other usages instances of ‘CommerceUsers’ to determine if they will be relevant to our customisation. We can see that this domain is associated to the following:-

  1. Profile import
    <sitecore>
      <commerceServer>
        <ProfileImport patch:source="CommerceServer.Profiles.config">
          <setting name="FieldForUserNameGeneration" value="GeneralInfo.email_address"/>
          <setting name="EmailAddressFieldName" value="GeneralInfo.email_address"/>
          <setting name="ProfileUniqueQueryKey" value="GeneralInfo.user_id"/>
          <setting name="ErrorThreshold" value="10"/>
          <setting name="Domain" value="CommerceUsers"/>
          <setting name="OutFile" value=""/>
          <setting name="MaxDegreeOfParallelism" value="4"/>
        </ProfileImport>
      </commerceServer>
    </sitecore>
    
  2. Site
    <sitecore>
      <sites>
        <site name="storefront" targetHostName="retail.dev.local" hostName="retail|storefront" commerceShopName="storefront" virtualFolder="/" physicalFolder="/" rootPath="/sitecore/content/storefront" startItem="/Home" dictionaryPath="/sitecore/content/storefront/global/dictionary" dictionaryAutoCreate="false" placeholderSettingsRoot="/sitecore/layout/Placeholder Settings/Project/Retail" mailTemplatesRoot="/sitecore/content/Storefront/Global/Mails" domain="CommerceUsers" allowDebug="true" htmlCacheSize="50MB" registryCacheSize="0" viewStateCacheSize="0" xslCacheSize="25MB" filteredItemsCacheSize="10MB" enablePreview="true" enableWebEdit="true" enableDebugger="true" disableClientData="false" cacheRenderingParameters="true" renderingParametersCacheSize="10MB" formsRoot="/sitecore/system/Modules/Web Forms for Marketers/Retail" loginPage="/login" enableItemLanguageFallback="true" patch:source="z.Sitecore.Demo.Retail.DevSettings.config" database="master" cacheHtml="false"/>
      </sites>
    </sitecore>
    
  3. Sitecore user repository
    <sitecore>
      <sitecoreUserRepository type="Sitecore.Commerce.Data.Customers.SitecoreUserRepository, Sitecore.Commerce" singleInstance="true" patch:source="Sitecore.Commerce.Customers.config">
        <param ref="entityFactory"/>
        <param desc="serializationProperty">__Serialization</param>
        <param desc="domain">CommerceUsers</param>
        <param desc="role">User Role</param>
      </sitecoreUserRepository>
    </sitecore>
    
  4. Profile provider for the Commerce Server
    <sitecore>
      <switchingProviders>
        <profile>
          <provider providerName="cs" storeFullNames="true" wildcard="%" domains="CommerceUsers" patch:source="CommerceServer.Profiles.config"/>
        </profile>
      </switchingProviders>
    </sitecore>
    

Profile Import

The profile import can be identified as out of scope for the base minimum requirements of setting up separate customer accounts per site, so we will leave this as an exercise to be completed separately leveraging the knowledge obtained from this investigation and implementation.

Site

Logically, this is the starting point for unique site configurations, but without the initial investigation developers would most likely fall into the trap of assuming the site’s domain attribute was the source domain used in creating users.

As we know that we should utilise the site configured domain when creating users, we don’t have to change the value of the domain for now. We will need to keep in mind that SitecoreUserRepository should be retrieving the value from the site configuration and not the static parameter it currently utilises.

Sitecore User Repository

In our initial investigation we determined that this repository was using the domain parameter to construct the db user’s name , however we didn’t identify how we could replace the parameter with the site domain attribute.

Taking a look closer at how the domain parameter is utilised within the repository, we see that there is a virtual method GetDomain() which we can override to return the site’s domain. Let’s do this now.

The Sitecore.Foundation.Multisite project seems to be the appropriate place to house the implementation to override the GetDomain() method as the project itself and the customisation we are about to implement are both agnostic of site-specific details.

The following snippet of codes shows how trivial updating the domain is by simply returning the site’s domain name. (You will need to reference the Sitecore.Commerce.dll if the project does not already include it)

using Sitecore.Commerce.Entities;

namespace Sitecore.Foundation.MultiSite.Infrastructure.Pipelines
{
  public class SitecoreUserRepository : Sitecore.Commerce.Data.Customers.SitecoreUserRepository
  {
    public SitecoreUserRepository(IEntityFactory entityFactory, string serializationProperty, string domain, string role)
      : base(entityFactory, serializationProperty, domain, role)
    {
    }

    protected override string GetDomain()
    {
      return Context.Domain.Name;
    }
  }
}

We follow this up by registering class in the config

<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/" xmlns:set="http://www.sitecore.net/xmlconfig/set/">
  <sitecore>
    <sitecoreUserRepository type="Sitecore.Commerce.Data.Customers.SitecoreUserRepository, Sitecore.Commerce">
      <patch:attribute name="type">Sitecore.Foundation.MultiSite.Infrastructure.Pipelines.SitecoreUserRepository, Sitecore.Foundation.MultiSite</patch:attribute>
    </sitecoreUserRepository>
  </sitecore>
</configuration>

We publish our changes, check the showconfig page to ensure the deploy was successful, and register a user (andrew2@test.com) with the debugger attached at a break point with our custom code as a sanity check that the code is being executed. Success!

If we change the domain in our site config to ‘DomainA’ we should expect to see a user registered under the new domain, right? Let’s check.

Register: The external id for the user default\andrew3@test.com is empty.

What happened? Well, upon investigation there are 2 issues here. The first is that the user was created in our Sitecore core database dbo.aspnet_Users table, however the user was not created as a commerce user in the Commerce Server profiles database dbo.UserObject table. The second is that the domain name does not match the ‘DomainA’ we applied and instead has been set to ‘default’.

As we still need to look at the configuration for Profile provider for the Commerce Server, let’s start there.

Profile provider for the Commerce Server

Following on from missing user in the Commerce Server profile database and our earlier investigation, we determined that the Commerce Server configuration has not been updated to accept profiles from a domain other than ‘CommerceUsers’, so let’s update the attribute to ‘DomainA’ and see what happens.

<?xml version="1.0"?>
<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/">
  <sitecore>
    <switchingProviders>
      <profile>
        <provider providerName="cs">
          <patch:attribute name="domains">DomainA</patch:attribute>
        </provider>
      </profile>
    </switchingProviders>
  </sitecore>
</configuration>

Similar to the SitecoreUserRepository configuration, we have just updated the domains attribute to handle a single domain solution, however the domains attribute can be configured with a comma-separated list of values, so no additional customisation is required other than to set the additional domains as required. i.e.

<patch:attribute name="domains">DomainA,DomainB</patch:attribute>

Configuring Domains

We appear to have missed something. In \Website\App_Config\Security folder, the Domains.config contains the registration for domains. In this instance we can just update ‘CommerceUsers’ directly to ‘DomainA’ in the domains configuration, or copy the domain definition and change the domain name appropriately. We add the file to our solution, publish and test again.

Note: Domains can also be maintained in the Domain Manager, but by adding the file to our solution, we can ensure that all developers domain configurations are automated and kept in sync via solution deployments.

<?xml version="1.0" encoding="utf-8"?>
<domains xmlns:sc="Sitecore">
  <domain name="sitecore" ensureAnonymousUser="false" />
  <domain name="extranet" />
  <domain name="default" isDefault="true" />
  <sc:templates>
    <domain type="Sitecore.Security.Domains.Domain, Sitecore.Kernel">
      <ensureAnonymousUser>true</ensureAnonymousUser>
      <locallyManaged>false</locallyManaged>
    </domain>
  </sc:templates>
  <domain name="DomainA" defaultProfileItemId="{0FFEC1BD-4D35-49CF-8B7D-7F01930834ED}" ensureAnonymousUser="false" />
  <domain name="CommerceCustomers" />
  <domain name="modules"/>
  <domain name="habitat"/>
  <domain name="DomainB" defaultProfileItemId="{0FFEC1BD-4D35-49CF-8B7D-7F01930834ED}" ensureAnonymousUser="false" />
</domains>

Success!

We are able to register users under a different domain that is associated with the site via configuration of the domain attribute. The accounts have also been confirmed in the Sitecore core database dbo.aspnet_Users table, and the Commerce Server profiles database dbo.UserObject table.

In the future, if we want to change the domain we only need update the site’s domain attribute and ensure the domain is registered in the Domains.config.

Summary

In wrapping up, we managed to get a better understanding of how customer accounts are associated to a site (by domain, not directly tied to a site), we were able to identify and implement the customisations required in order to support a multi-commerce-site solution, and we also ended up getting our solution into a state we can manage adding and assigning domains via config to be deployed with the solution without any manual steps outside of configuration.