Custom Promotion Criteria

Promotion criteria in Ucommerce are used to determine when to trigger a discount on an order.

Ucommerce has built-in criteria for handling most use cases. However, if the need arises, it's quite easy to create a custom criterion.

Create a Custom Criterion

Creating a Definition

To create a definition, follow these steps:

  1. Create a definition and add it to your database. The definition must be of the criterion definition type.

  2. Add appropriate definition fields to it. Built-in Ucommerce data types will work out of the box.

  3. If you wish to be able to reuse the criterion for future projects we recommend you create a background service to automate this process.

Example: buy-less-than Criterion

Below is an example of setting up a criterion that triggers only when a customer is buying less than a given quantity.

public class SetupCustomCriterion : BackgroundService
{
    private readonly IServiceProvider _serviceProvider;

    /// <inheritdoc />
    public SetupCustomCriterion(IServiceProvider serviceProvider)
    {
        _serviceProvider = serviceProvider;
    }

    /// <inheritdoc />
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        await using var asyncScope = _serviceProvider.CreateAsyncScope();
        var dbContext = asyncScope.ServiceProvider.GetRequiredService<UcommerceDbContext>();
        if (dbContext.Set<DefinitionEntity>().Any(x => x.Name == "Buy At Most"))
        {
            return;
        }

        // Set up data using dbContext
        // Find the TargetOrderLine data type
        var dataType = dbContext.Set<DataTypeEntity>()
            .FirstOrDefault(x => x.Guid.ToString() == "b5295545-5583-41e1-8c93-7bc921c09e27");
        var defFields = new List<DefinitionFieldEntity>();
        defFields.Add(new DefinitionFieldEntity
        {
            Name = "Max Amount",
            DataTypeId = 3,
            DisplayOnSite = true,
            DefaultValue = "0",
            RenderInEditor = true,
            Guid = Guid.Parse("42085d44-3e81-4d94-aad8-8699ae7d35b0")
        });

        defFields.Add(new DefinitionFieldEntity
        {
            Name = "Target Orderline",
            DefaultValue = "0",
            DisplayOnSite = true,
            RenderInEditor = true,
            DataType = dataType,
            Guid = Guid.Parse("aa793b3f-2baf-45fb-a162-cb675e8d9b64")
        });
        dbContext.Set<DefinitionEntity>()
            .Add(new DefinitionEntity
            {
                BuiltIn = false,
                Description = "buy at Most criterion definition",
                Name = "Buy At Most",
                DefinitionTypeId = 5434834, //Id of the Criterion Definition Type
                DefinitionFields = defFields,
                Guid = Guid.Parse("99ab73d5-22ee-425c-97f8-f9794ed01944")
            });

        await dbContext.SaveChangesAsync(stoppingToken);
    }
}

Using explicit GUIDs makes subsequent steps easier.

Background services are run on every startup, so it is important to have a check to prevent multiple additions of the same values.

Implementing a Pipeline Task for Satisfaction Check

To trigger a criterion we need to extend the SatisfiedCriteria pipeline with a pipeline task that checks whether the criterion is satisfied. There are four unique concepts to be aware of regarding this task:

  • CriterionDTO is a DTO connecting a criterion with its properties.

  • context.Output.CriterionDtos is a dictionary of CriterionDTO grouped by their definition.

  • Context.Output.SatisfiedOrderLineCriteria is a dictionary of lists containing criteria grouped by the order line that satisfied them. Use this for order line-level criteria.

  • context.Output.SatisfiedOrderCriteria is a list of criteria that are satisfied by the order. Use this for order-level criteria.

Remember to register your pipeline task through the pipeline builder.

Example

Here's an example of a pipeline task for checking if the buy-at-most criterion is triggered by an order. Since it can target both line- and order-level this task can handle both cases. This will not be necessary in most cases.

 /// <summary>
 /// Task for checking buyAtMost criteria.
 /// </summary>
public class BuyAtMostCriteriaPipelineTask : AbstractPipelineTask<SatisfiedCriteriaInput, SatisfiedCriteriaOutput>
{
    /// <inheritdoc />
    public override Task Execute(PipelineContext<SatisfiedCriteriaInput, SatisfiedCriteriaOutput> context, CancellationToken cancellationToken)
    {
        if (!context.Output.CriterionDtos.TryGetValue(
            Guid.Parse("99ab73d5-22ee-425c-97f8-f9794ed01944"), 
            out var buyAtMostCriterionDtos))
        {
            return Task.CompletedTask;
        }

        var orderLineBuyAtMostCriterionDtos = buyAtMostCriterionDtos.Where(x => 
                x.Properties["aa793b3f-2baf-45fb-a162-cb675e8d9b64"]
                    .Value == BuyAtMostCriteriaConstants.ORDER_LINE_ENUM)
            .ToImmutableList();
        var orderLevelBuyAtMostCriterionDtos = buyAtMostCriterionDtos.Where(x => 
                x.Properties["aa793b3f-2baf-45fb-a162-cb675e8d9b64"]
                    .Value == BuyAtMostCriteriaConstants.ORDER_ENUM)
            .ToImmutableList();

        var satisfiedCriteriaOrderLevel = GetBuyAtMostCriteriaOrderLevel(
                orderLevelBuyAtMostCriterionDtos,
                context.Output.Cart.OrderLines.ToImmutableList())
            .ToImmutableList();
        context.Output.SatisfiedOrderCriteria = context.Output.SatisfiedOrderCriteria
            .Concat(satisfiedCriteriaOrderLevel)
            .ToImmutableList();

        var satisfiedCriteriaOrderLineLevel = GetBuyAtMostCriteriaOrderLineLevel(
                orderLineBuyAtMostCriterionDtos,
                context.Output.Cart.OrderLines.ToImmutableList())
            .ToImmutableList();

        foreach (var satisfiedCriterion in satisfiedCriteriaOrderLineLevel)
        {
            if (context.Output.SatisfiedOrderLineCriteria.TryAdd(
                satisfiedCriterion.OrderLine, 
                ImmutableList.Create(satisfiedCriterion.Criterion)))
            {
                continue;
            }

            context.Output.SatisfiedOrderLineCriteria[satisfiedCriterion.OrderLine] = 
                context.Output.SatisfiedOrderLineCriteria[satisfiedCriterion.OrderLine]
                    .Add(satisfiedCriterion.Criterion);
        }

        return Task.CompletedTask;
    }

    /// <summary>
    /// Gets the buyAtMost criteria that are satisfied by the given order lines.
    /// </summary>
    /// <returns>IEnumerable of buyAtMost criteria where the buyAtMost of the order 
    /// lines given is above the threshold if the TargetOrderLine is true.
    /// If the TargetOrderline is false it instead checks if the order lines in total 
    /// have a higher buyAtMost than the threshold.</returns>
    protected virtual IEnumerable<CriterionEntity> GetBuyAtMostCriteriaOrderLevel(
        ImmutableList<CriterionDTO> criterionDtos,
        IReadOnlyCollection<OrderLineEntity> orderLines)
    {
        foreach (var criterionDto in criterionDtos)
        {
            var generated = orderLines.Where(o => o.Properties
                    .Any(p => p.Key == Constants.OrderProperties.GENERATED))
                .ToList();
            var any = orderLines.Where(o => !generated.Contains(o))
                .Sum(x => x.Quantity) <= int.Parse(
                    criterionDto.Properties["42085d44-3e81-4d94-aad8-8699ae7d35b0"]
                        .Value!);
            if (any)
            {
                yield return criterionDto.Criterion;
            }
        }
    }

    /// <summary>
    /// Gets the buyAtMost criteria that are satisfied by the given order lines.
    /// </summary>
    /// <returns>IEnumerable of buyAtMost criteria where the buyAtMost of the order 
    /// lines given is above the threshold if the TargetOrderLine is true.
    /// If the TargetOrderline is false it instead checks if the order lines in total 
    /// have a higher buyAtMost that the threshold.</returns>
    protected virtual IEnumerable<SatisfiedOrderlineDTO> GetBuyAtMostCriteriaOrderLineLevel(
        ImmutableList<CriterionDTO> criterionDtos,
        IReadOnlyCollection<OrderLineEntity> orderLines)
    {
        var generated = orderLines.Where(o => o.Properties
                .Any(p => p.Key == Constants.OrderProperties.GENERATED))
            .ToList();
        var viableOrderLines = orderLines.Where(o => !generated.Contains(o));

        foreach (var criterionDto in criterionDtos)
        {
            foreach (var orderLine in viableOrderLines)
            {
                if (orderLine.Quantity <= int.Parse(
                        criterionDto.Properties["42085d44-3e81-4d94-aad8-8699ae7d35b0"]
                            .Value!))
                {
                    yield return new SatisfiedOrderlineDTO(orderLine, criterionDto.Criterion);
                }
            }
        }
    }
}

Validation (Optional)

It is possible to set validation rules for a criterion using FluentValidation. To make this easier, inherit from UpdateCriteriaInputValidatorBase in namespace Ucommerce.Web.BackOffice.Validators.Promotions.Criteria.UpdateCriteria to set rules for your custom criteria.

UpdateCriteriaInputValidatorBase contains the method RuleForUpdatePropertyValue(string propertyName) where propertyName is the definition field´s name.

After creating a validator it needs to be registered as a service in the program.cs file.

Example

Here's an example of custom rules for the Buy-At-Most criterion that was created earlier.

public class UpdateCriteriaBuyAtMostCriteriaValidator : UpdateCriteriaInputValidatorBase
{
    /// <summary>
    /// Initializes a new instance of the <see cref="UpdateCriteriaQuantityCriteriaValidator"/> class.
    /// </summary>
    public UpdateCriteriaBuyAtMostCriteriaValidator()
        : base("99ab73d5-22ee-425c-97f8-f9794ed01944")
    {
        RuleForUpdatePropertyValue("Max Amount")
            .Required()
            .GreaterThan(2)
            .WithMessage("Must be a positive number higher than 2");

        RuleForUpdatePropertyEnum("Target Orderline")
            .Required()
            .HasEnumValue(CriteriaTypeConstants.QuantityCriterion.ORDER_ENUM, CriteriaTypeConstants.QuantityCriterion.ORDER_LINE_ENUM)
            .WithMessage("Must be either 'Order' or 'Order Line'");
    }
}

RuleForUpdatePropertyValue is case sensitive so the string must exactly match the definition field name in the database.

Tips for Reusability

To facilitate reuse of a custom criterion across projects, it is recommended to create an extension method that registers the needed services.

This is an example of a method for registering the different parts of a custom criterion:

public static IUcommerceBuilder AddCustomCriterion(this IUcommerceBuilder builder)
{
    builder.PipelineBuilder.InsertLast<
        IPipelineTask<SatisfiedCriteriaInput, SatisfiedCriteriaOutput>, 
        BuyAtMostCriteriaPipelineTask>();
    builder.Services.AddHostedService<SetupCustomCriterion>();
    builder.Services.AddSingleton<UpdateCriteriaInputValidatorBase, UpdateCriteriaBuyAtMostCriteriaValidator>();
    return builder;
}

That way, it is now possible to add the criterion to any Ucommerce solution like this:

var ucommerceBuilder = builder.Services
    .AddUcommerce(builder.Configuration)
    .AddBackOffice()
    .AddWebSite()
    .AddInProcess<RouteParser>()
    .UcommerceBuilder
    .AddCustomCriterion(); //Here

Last updated