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:
Create a 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.
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: