Extending Criteria

Criteria is the way to determine when to trigger a discount on an order.

Ucommerce has built-in criteria for handling most use cases. But if needed, it is possible to create a custom criterion.

Creating a definition

To get started, create a criterion definition and add it to your database. The definition must be of the criterion definition type. Add appropriate definition fields to it. Built-in Ucommerce data types will work out of the box. If you wish to be able to reuse the criterion for future projects we recommend you create a background service to automate this process.

This is an example of a definition set up through code for a custom criterion made to trigger 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>()
                        .FirstOrDefault(x => x.Name == "Buy At Most") is not null)
                {
                    return;
                }

                // Set up data using dbContext
                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);
            }
        }        

Setting explicit values for GUIDs here makes the next step easier.

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

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 lists of CriterionDTOs grouped by 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 is an example of a pipeline task for checking if the buy-at-most criterion is triggered by an order. Since it can target both orderline and order level this pipeline 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 orderlines.
    /// </summary>
    /// <returns>IEnumerable of buyAtMost Criteria where the buyAtMost of the Orderlines given is above the threshold if the TargetOrderLine is true.
    /// If the Target Orderline is false it instead checks if the Orderlines in total have a higher buyAtMost that 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 orderlines.
    /// </summary>
    /// <returns>IEnumerable of buyAtMost Criteria where the buyAtMost of the Orderlines given is above the threshold if the TargetOrderLine is true.
    /// If the Target Orderline is false it instead checks if the Orderlines 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);
                }
            }
        }
    }
}

Optional: Validation

It is possible to set validation rules for a criterion using FluentValidation. To make this easier, use the UpdateCriteriaInputValidatorBase to set rules for your custom criteria.

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

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

Here is 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 and the Ucommerce frontend will always lowercase the first letter of property names.

Tips for making custom criteria reusable

To make reusing a custom criterion in a different project easier, it is recommended to create the custom criterion definition by using a background service. This makes it possible to create a method for registering all the relevant parts inside a single method.

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;
}

Last updated