Skip to content

OrderCalc Promo Extension Authoring Guide

This guide is for developers who build custom qualifications, limits, and rewards in their company app. It starts with a working extension, then covers choosing the right kind, what the base classes do for you, writing rewards and limits, describing settings so the management app can edit them, changing an extension that promos already use, and testing.

To build promos from extensions that already exist, see the Authoring And Management Guide. To check promos from your own systems, see the API Guide.

Guide Map

If you need to... Start here
Get a working extension into your company app Your First Extension
Decide between a qualification, a limit, and a reward Choosing An Extension Kind
Know what the base classes do and what your code receives Authoring Contract
Change the order safely Writing A Reward
Control how often a promo or reward can be used Writing A Limit
Describe settings so the management app can edit them Configuration Annotations And Constraints
Rename or change an extension that promos already use Renaming And New Company App Builds
Find out why an extension is missing or doesn't apply Troubleshooting
Test an extension before its build goes live Testing An Extension

Your First Extension

This qualification lets a promo apply only to orders that weigh no more than a configured amount, for example free shipping on light orders. Add it to the project that contains your company app's order plan:

using System.Collections.Generic;
using System.Threading.Tasks;
using Exigo.OrderCalc;
using Exigo.OrderCalc.Promotions.Abstractions.Core;
using Exigo.OrderCalc.Promotions.Implementations.Core;
using Exigo.OrderCalc.Promotions.Implementations.Qualifications;
using Exigo.OrderCalc.Promotions.SchemaGen.Attributes;
using Exigo.OrderCalc.Promotions.SchemaGen.Attributes.Constraints;
using Exigo.OrderCalc.Providers;

namespace MyCompany.Promotions;

public class MaximumWeightQualification(
    IAsyncOrderSourceData orderSource,
    IAsyncPromoData promoData,
    IFeedbackProvider feedback)
    : QualificationExtensionBase<MaximumWeightQualification.Config>(orderSource, promoData, feedback)
{
    public override string ExtensionName => "MaximumWeight";

    protected override Task<IReadOnlyList<CriterionOutcome>> EvaluateAsync(
        QualificationEvaluationContext<Config> context)
    {
        var outcome = CriterionOutcome.CompareDecimal(
            "OrderWeight",
            ComparisonOperator.LessThanOrEqual,
            threshold: context.Configuration.MaximumWeight,
            actual: context.Order.WeightTotal);

        return Task.FromResult<IReadOnlyList<CriterionOutcome>>([outcome]);
    }

    [PromoSchema(typeof(MaximumWeightQualification), "Qualifies an order whose total weight is at most a maximum.")]
    public record Config
    {
        [SchemaTitle("Maximum weight")]
        [SchemaDescription("The most the order may weigh, in the unit your item weights use.")]
        [DecimalRange(Min = 0)]
        public required decimal MaximumWeight { get; init; }
    }
}

What each part does:

  • QualificationExtensionBase<Config> parses the promo's saved settings into Config, validates them, and calls your EvaluateAsync with the order, the promo, and the typed settings.
  • EvaluateAsync returns criterion outcomes rather than true or false. The qualification passes only when all of its outcomes are satisfied.
  • ExtensionName is the name evaluation reports use. Promo authors see the class name.
  • PromoSchema ties Config to the extension and describes it. The annotations on MaximumWeight give the field its label, its help text, and a lower bound.

Build your company app. The build checks the annotations and generates a definition for each class marked with PromoSchema. Once that company app build goes live, the management app offers MaximumWeightQualification among the qualifications a promo can use, with a Maximum weight field. A promo author's setting is saved as JSON, such as {"maximumWeight": 5}.

To try the extension before its build goes live, call EvaluateAsync with that JSON and a test order; see Testing An Extension.

Choosing An Extension Kind

Kind Answers Implement Changes the order
Qualification Does this order qualify for the promo? EvaluateAsync, returning criterion outcomes No
Limit Can this promo or reward still be used? EvaluatePromotionLimitsAsync, EvaluateRewardLimitsAsync, or both, returning criterion outcomes No
Reward What changes on the order once the promo applies? ApplyAsync Yes
If the requirement sounds like this Choose
"The order must contain one of these products." Qualification
"The order subtotal must exceed a threshold." Qualification
"This promo can only be used three times per customer." Limit
"This reward may only apply once per order." Limit
"Give 10 percent off these items." Reward
"Add a free item when the promo applies." Reward

Most mistakes come from using one kind to do another's job:

  • Don't use a qualification to restrict how often a promo is used. That rule asks whether use is still available, not whether the order qualifies, so it is a limit.
  • Don't use a reward to decide eligibility. A reward runs only after the promo applies. If reward code has to decide whether the promo should apply at all, that rule belongs in a qualification.
  • Don't use a qualification to change the order. Qualifications report facts. Any change to the order belongs in a reward.
  • Don't use a limit for a rule about the order or the customer. Order state, customer type, region, and product presence are qualifications, even when they feel restrictive.
  • Don't use a limit to cap what a reward gives. A limit only decides whether the promo or a reward can still be used: how many times, or until when. A cap on how much a reward gives, such as a maximum discount or a number of added units, belongs in the reward's configuration; see Caps Are Not Limits.

Authoring Contract

Base Classes

Each extension derives from the generic base class for its kind. The base class parses and validates the saved configuration, then calls your override with a typed context.

Kind Base class Override Receives
Qualification QualificationExtensionBase<TConfig> EvaluateAsync QualificationEvaluationContext<TConfig>
Reward RewardExtensionBase<TConfig> ApplyAsync RewardApplicationContext<TConfig>
Limit One of the limit base classes below EvaluatePromotionLimitsAsync, EvaluateRewardLimitsAsync, or both PromotionLimitEvaluationContext<TConfig>, RewardLimitEvaluationContext<TConfig>

A limit's base class decides where it can be attached:

Base class Can be used as Implement
PromotionLimitExtensionBase<TConfig> A promo limit only EvaluatePromotionLimitsAsync
RewardLimitExtensionBase<TConfig> A reward limit only EvaluateRewardLimitsAsync
DualScopeLimitExtensionBase<TConfig> A promo limit or a reward limit Both

The management app only offers a limit where its base class allows it, and a limit that declares neither placement fails the build.

An extension must be a public, non-abstract class in the same assembly as your company app's order plan. The engine looks for extensions only there and among the built-in ones.

Constructor Services

The engine creates each extension through dependency injection. Every base class takes IAsyncOrderSourceData for order and catalog data, IAsyncPromoData for promo usage counts, and IFeedbackProvider for tracing, and exposes them to your code as OrderSource, PromoData, and Feedback.

Ask for any other service in the same constructor, such as IPromoUsageRecorder for a limit that counts a reward's applications in the order, or TimeProvider for a limit that compares against the clock. Ask only for services the host provides: if a service can't be supplied, the extension can't be created, and When An Extension Fails describes what follows.

Naming

An extension has three names:

  • The class name is what promo authors see in the management app.
  • ExtensionName is what evaluation reports use: the extension field of each criterion report in the insights API, and the name in configuration error messages.
  • The assembly name and full type name, including the namespace, are what saved promos are matched on; see Renaming And New Company App Builds.

The built-in rewards return their class name from ExtensionName, as in nameof(OrderSubtotalDiscount). The built-in qualifications and limits drop the suffix, so MinimumOrderAmountQualification reports as MinimumOrderAmount. Either convention works. Choose names that describe the behavior and keep them: renaming the class changes what authors see and what saved promos are matched on, and renaming ExtensionName changes what evaluation reports show.

What Each Context Carries

Context Carries
QualificationEvaluationContext<TConfig> Order, Promo, Qualification, typed Configuration
RewardApplicationContext<TConfig> Order, Promo, Reward, typed Configuration, StartingOrderMeasurements
PromotionLimitEvaluationContext<TConfig> Order, Promo, typed Configuration
RewardLimitEvaluationContext<TConfig> Order, Promo, Reward, typed Configuration, and AtPromotionScope()

DualScopeLimitExtensionBase has no default for either check. When a dual-scope limit asks the same question in both placements, delegate explicitly so the shared meaning is visible. The built-in MaxUsesPerCustomerLimit counts the customer's uses of the promo wherever it is attached:

protected override Task<IReadOnlyList<CriterionOutcome>> EvaluateRewardLimitsAsync(
    RewardLimitEvaluationContext<Config> context)
    => EvaluatePromotionLimitsAsync(context.AtPromotionScope());

Outcomes, Not Booleans

Qualifications and limits return a list of CriterionOutcome facts. The engine reads the verdict from them, and the same facts explain it: each becomes an entry in criteria[].facts[] of the insights response, under a criterion report whose extension is your ExtensionName. Reading The Response and The Discriminator Map in the API Guide show the full shape. Report facts specific enough that a caller can tell which condition passed or failed without reading your code.

CriterionOutcome has factory methods for the common shapes, such as CompareDecimal, SetInt, SetString, BooleanCondition, and Quota. Always return at least one outcome. An empty list counts as satisfied, so a qualification that returns nothing passes every order.

Validating Configuration

Before your override runs, the base class:

  1. Receives the saved configuration as a JSON string.
  2. Parses it into TConfig.
  3. Calls ValidateConfiguration.
  4. Calls your override with the typed configuration.

When parsing or validation fails, the base class throws InvalidPromoConfigurationException, naming your ExtensionName, instead of calling your override.

Override ValidateConfiguration for rules that annotations can't express. If the weight qualification also had a MinimumWeight setting, a maximum below the minimum would be a rule across two fields:

protected override ConfigurationResult<Config> ValidateConfiguration(Config config)
    => config.MaximumWeight >= config.MinimumWeight
        ? new ConfigurationResult<Config>.Success(config)
        : new ConfigurationResult<Config>.ValidationFailed(
            ExtensionName, config, "Maximum weight must be at least the minimum weight.");

Return Success to accept the configuration, or ValidationFailed with a reason to reject it. Validation Boundaries covers which rules belong in annotations, here, or in your evaluation code.

When An Extension Fails

Any exception from a qualification or promo limit, whether from a service the host can't supply, configuration that fails to parse or validate, or an error in your own code, becomes an extension error, which counts as not satisfied. When a reward limit throws, that reward is skipped. A reward that throws is skipped and logged, and changes it already made stay on the order.

In the insights response, an extension error is reported as a CriterionExtensionError whose message says what went wrong.

Writing A Reward

This reward takes a fixed amount off the order subtotal. It never discounts more than what is left of the subtotal, so it can't reduce shipping, and it traces why when it gives nothing:

using System;
using System.Threading.Tasks;
using Exigo.OrderCalc;
using Exigo.OrderCalc.Promotions.Abstractions.Core;
using Exigo.OrderCalc.Promotions.Implementations.Core;
using Exigo.OrderCalc.Promotions.Implementations.Rewards;
using Exigo.OrderCalc.Promotions.SchemaGen.Attributes;
using Exigo.OrderCalc.Promotions.SchemaGen.Attributes.Constraints;
using Exigo.OrderCalc.Providers;

namespace MyCompany.Promotions;

[RewardTypeSchema(RewardTypeIds.OrderSubtotalDiscount)]
public class SubtotalAmountOffReward(
    IAsyncOrderSourceData orderSource,
    IAsyncPromoData promoData,
    IFeedbackProvider feedback)
    : RewardExtensionBase<SubtotalAmountOffReward.Config>(orderSource, promoData, feedback)
{
    public override string ExtensionName => nameof(SubtotalAmountOffReward);

    protected override Task ApplyAsync(RewardApplicationContext<Config> context)
    {
        // A subtotal reward discounts only what is left of the subtotal, never the shipping.
        var remainingSubtotal = Math.Max(0m, context.Order.SubTotal - context.Order.DiscountTotal);
        var discount = Math.Min(context.Configuration.Amount, remainingSubtotal);

        // Change the order through an effect helper, then record the application.
        if (ApplyOrderDiscountEffect(context, discount) > 0m)
            RecordRewardApplication(context);
        else
            Feedback.Trace($"{ExtensionName} applied nothing: no subtotal left to discount.");

        return Task.CompletedTask;
    }

    [PromoSchema(typeof(SubtotalAmountOffReward), "Takes a fixed amount off the order subtotal.")]
    public record Config
    {
        [SchemaTitle("Discount amount")]
        [SchemaFormat(SchemaFormats.Currency)]
        [DecimalRange(Min = 0)]
        public required decimal Amount { get; init; }
    }
}

Every reward declares its classification with RewardTypeSchema. The attribute goes on the reward class, not on Config, and a reward without it fails the build.

When A Reward Runs

The classification in RewardTypeSchema decides when in the calculation the reward runs. It does not limit what the reward gives: RewardTypeIds.OrderShippingDiscount, for example, covers a percentage, an amount, or a full waiver.

Classification Runs Use it for
RewardTypeIds.AddItems, RewardTypeIds.ItemShippingDiscount Before the main calculation Adding items or changing item shipping bases, which later steps must see
RewardTypeIds.ItemPriceDiscount, RewardTypeIds.OrderSubtotalDiscount After the main calculation Discounting item prices or the subtotal of the calculated order
RewardTypeIds.OrderShippingDiscount After the price and subtotal rewards Reducing the shipping amount the calculation produced

The remaining constants, RewardTypeIds.ItemFixedDiscount, RewardTypeIds.OrderFixedDiscount, RewardTypeIds.ShippingFixedDiscount, and RewardTypeIds.FreeShipping, also work, but the ones above are the recommended choices. A reward with the wrong classification still builds, but runs at the wrong point in the calculation.

Earn From The Starting Order

context.StartingOrderMeasurements holds the order's totals and product quantities as they were before any promotion changed them: ValueOf with an OrderMeasurementSource member, such as ValueOf(OrderMeasurementSource.BusinessVolume), for the merchandise subtotal, business volume, commissionable volume, or Other 1 through Other 10, and QuantityOf(itemCode) for a product. Decide what the reward earns from these values, then apply the result to context.Order. That way an item added or a discount given by another reward never changes what your reward earns.

A reward can't read qualification results; it states its own thresholds. A reward never names a currency: amounts are in the order's currency.

Record What The Reward Changes

Change the order through the effect helpers on RewardExtensionBase, such as ApplyOrderDiscountEffect, ApplyLineItemDiscountEffects, ApplyShippingDiscountEffect, ApplyFreeShippingEffect, ApplyAddedItemEffect, and ApplyItemShippingBasisReductionEffect. They record each change so it can be reset on the next calculation and reported to callers; a change made any other way is neither reset nor reported. After the reward has applied, call RecordRewardApplication(context). Per-order limits count these calls, so a reward that never calls it is never counted.

Keep Rewards Safe

A reward changes prices, discounts, line items, and shipping directly, so a mistake reaches the customer's order:

  • Check everything the reward needs, such as products and prices, before changing anything. Changes made before an exception stay on the order.
  • When something the reward needs is missing, apply nothing and call Feedback.Trace(...) to say why, rather than guessing.
  • Change only the order. A reward can run more than once in one request: when a calculation also prices the order's other ship methods, the rewards that run before the main calculation and the shipping rewards run again on a temporary order for each one. Don't send messages, write records, or call services that change data from reward code.

The built-in add-items reward works this way: it resolves every product and price before adding any line, adds a bundle completely or not at all, and never lets a price drop below zero.

Writing A Limit

This reward limit lets one reward apply at most a configured number of times in one order. It has the same shape as the built-in MaxUsesPerOrderLimit:

using System.Collections.Generic;
using System.Threading.Tasks;
using Exigo.OrderCalc;
using Exigo.OrderCalc.Promotions.Abstractions.Core;
using Exigo.OrderCalc.Promotions.Implementations.Core;
using Exigo.OrderCalc.Promotions.Implementations.Limits;
using Exigo.OrderCalc.Promotions.SchemaGen.Attributes;
using Exigo.OrderCalc.Promotions.SchemaGen.Attributes.Constraints;
using Exigo.OrderCalc.Providers;

namespace MyCompany.Promotions;

public class RewardUsesPerOrderLimit(
    IAsyncOrderSourceData orderSource,
    IAsyncPromoData promoData,
    IPromoUsageRecorder usageRecorder,
    IFeedbackProvider feedback)
    : RewardLimitExtensionBase<RewardUsesPerOrderLimit.Config>(orderSource, promoData, feedback)
{
    public override string ExtensionName => "RewardUsesPerOrder";

    protected override async Task<IReadOnlyList<CriterionOutcome>> EvaluateRewardLimitsAsync(
        RewardLimitEvaluationContext<Config> context)
    {
        var applications = await usageRecorder.GetRewardApplicationCountAsync(
            context.Promo.PromoId, context.Reward.RewardId);

        return [CriterionOutcome.Quota(
            CriterionOutcome.CriterionQuota.Scopes.PerOrder,
            max: context.Configuration.MaxUses,
            current: applications)];
    }

    [PromoSchema(typeof(RewardUsesPerOrderLimit), "Limits how many times one reward may be applied in a single order.")]
    public record Config
    {
        [SchemaTitle("Maximum uses per order")]
        [SchemaDescription("The greatest number of times this reward may be applied in one order.")]
        [IntRange(Min = 1)]
        public required int MaxUses { get; init; }
    }
}
  • Deriving from RewardLimitExtensionBase makes it a reward limit only. Use PromotionLimitExtensionBase or DualScopeLimitExtensionBase for the other placements.
  • IPromoUsageRecorder is an extra constructor service. GetRewardApplicationCountAsync counts the reward's RecordRewardApplication calls for the order, so the limit counts applications, not units.
  • A Quota outcome is satisfied while the current count is below the maximum. When it isn't, that reward is skipped, and the promo's other rewards still apply.
  • A promo author's setting is saved as {"maxUses": 1}.

Configuration Annotations And Constraints

Define an extension's settings as a nested Config record. The build turns its annotations into a JSON Schema, writing title, description, and format from SchemaTitle, SchemaDescription, and SchemaFormat, and the management app uses that schema to render each field and to shape the configuration it saves.

The constraint annotations are in the Exigo.OrderCalc.Promotions.SchemaGen.Attributes.Constraints namespace; the others are in Exigo.OrderCalc.Promotions.SchemaGen.Attributes.

Annotation Reference

Type-Level Annotations

Annotation Use it Notes
PromoSchema On every Config Ties the configuration to its extension and carries the extension's description. Without it, the build generates no definition for the extension, so the management app can't offer it
RewardTypeSchema On every reward class, not on Config Required. Its classification decides when in the calculation the reward runs, not what it gives; see When A Reward Runs

Descriptive Annotations

Annotation Use it when Notes
SchemaTitle The label derived from the property name isn't clear enough Sets the field label
SchemaDescription A field needs explanation Explain what the value means; don't restate the property name
SchemaFormat A field has a value format, a lookup, or a display hint A machine-readable token the management app acts on, usually a SchemaFormats constant; see Lookup Formats

Constraint Annotations

Annotation Use it when Notes
IntRange Integer values need minimum or maximum bounds Good for counts, quantities, and thresholds
DecimalRange Decimal values need bounds Good for money and percentages
SchemaMinLength / SchemaMaxLength Strings have length bounds Good for codes and labels
SchemaPattern A string must match a regex pattern Use carefully; prefer readable patterns
DateRange Date or time values have bounded windows Good for date-oriented configs
SchemaMinItems / SchemaMaxItems Collections have size bounds Good for lookup lists
SchemaUniqueItems Collection members should not repeat Good for lists of selected identifiers
SchemaMultipleOf Numeric precision matters Good for currency-like increments

Advanced Annotations

Annotation Use it when Notes
PromoUnion One config concept has multiple concrete shapes Use when a discriminated union is genuinely needed
PromoUnionCase A union needs explicit cases Keep discriminator values stable
SchemaMapEditor A property is a dictionary, such as tiers keyed by threshold Required on every dictionary property unless SchemaOverride replaces its schema; names the key and value for the editor
SchemaOverride The inferred schema is not expressive enough Treat as an escape hatch, not the default path. It replaces every other annotation on that property

Common Patterns

If the field is Annotate it with
A lookup-backed identifier SchemaTitle, SchemaDescription, and a lookup SchemaFormat
A numeric threshold or quota Descriptive annotations and IntRange or DecimalRange
An amount of money Descriptive annotations, SchemaFormat(SchemaFormats.Currency), and numeric bounds. Don't add a currency field: amounts are always in the order's currency
A list of selected values Descriptive annotations and collection constraints such as SchemaMinItems or SchemaUniqueItems
A field with special presentation Descriptive annotations and a display-hint SchemaFormat

Build-Time Checks

The schema generator checks each extension and its annotations while the company app builds. Every check is an error, so a mismatch fails the build instead of showing up later in the management app or at runtime.

Diagnostic Fails the build when
PSCHEMA001 IntRange is on a property that isn't an integer
PSCHEMA002 DecimalRange is on a property that isn't a decimal or floating-point number
PSCHEMA003 SchemaMinLength, SchemaMaxLength, or SchemaPattern is on a property that isn't a string
PSCHEMA005 PromoSchema names a type that isn't a qualification, limit, or reward extension
PSCHEMA007 DateRange is on a property that isn't a DateTime or a string
PSCHEMA008 SchemaMinItems, SchemaMaxItems, or SchemaUniqueItems is on a property that isn't an array or collection
PSCHEMA009 A range sets both an inclusive and an exclusive bound on the same end, such as Min with ExclusiveMin
PSCHEMA010 SchemaMultipleOf is on a property that isn't numeric
PSCHEMA011 A DateRange bound isn't a valid ISO 8601 date, date-time, or time
PSCHEMA012 A reward class has no RewardTypeSchema
PSCHEMA013 PromoUnion is on a type that isn't abstract
PSCHEMA014 A PromoUnion has no PromoUnionCase
PSCHEMA015 A PromoUnionCase type doesn't derive from the union's base type
PSCHEMA016 Two cases of one union share a discriminator value
PSCHEMA017 A dictionary property has neither SchemaMapEditor nor SchemaOverride
PSCHEMA018 SchemaMapEditor is on a property that isn't a dictionary, or has a blank key or value label
PSCHEMA019 A dictionary's key or value type can't be shown in the editor; use a string, integer, number, boolean, or enum
PSCHEMA020 A limit doesn't declare where it can be attached

Lookup Formats

For select-style inputs, use one of the well-known lookup formats instead of inventing ad hoc strings. Formats that end in -lookup render a select control backed by reference data rather than a free-text input.

Lookup format Typical use
SchemaFormats.ItemCodeLookup Item-code selection
SchemaFormats.ItemIdLookup Item-ID selection
SchemaFormats.CurrencyCodeLookup Currency-code selection
SchemaFormats.CustomerTypeLookup Customer-type selection
SchemaFormats.PriceTypeLookup Price-type selection
SchemaFormats.PeriodTypeLookup Period-type selection
SchemaFormats.CountryCodeLookup Country-code selection
SchemaFormats.WarehouseLookup Warehouse selection
SchemaFormats.OrderTypeLookup Order-type selection
SchemaFormats.ShipMethodLookup Ship-method selection
SchemaFormats.VolumeChannelLookup Volume-channel selection
SchemaFormats.OrderMeasurementLookup Order measurement selection: merchandise subtotal, business volume, commissionable volume, or Other 1 through Other 10

Collection fields

Collection fields use the same lookup format as single-value fields. The field type and generated schema decide whether the management app renders one selected value or a list of selected values.

The built-in OrderTypeQualification shows a lookup list in practice. Its orderTypeIds setting is a list of integers with SchemaFormats.OrderTypeLookup and SchemaMinItems(1), and its isInverted switch turns the selected order types from an include list into an exclude list:

{
    "orderTypeIds": [1, 2, 3],
    "isInverted": false
}

The built-in non-lookup format constants fall into three groups:

  • value formats such as Currency, Percentage, OrderMeasurementValue (a value measured in the order measurement chosen alongside it), DateTime, Date, and Time,
  • standard validation formats such as Uri and Email,
  • and display-hint formats such as Switch, Color, Multiline, RadioGroup, and Segmented.

Use the SchemaFormats constants; they are the formats the management app recognizes.

Validation Boundaries

Use the earliest tool that can express a rule: annotations, then ValidateConfiguration, then your evaluation or apply code.

Rule type Best home
Simple numeric, string, or collection bounds Constraint annotations
Display and lookup intent SchemaFormat and descriptive annotations
Rules across fields ValidateConfiguration
Rules that depend on live data or the environment Your evaluation or apply code

For example:

  • "Quantity must be at least 1" belongs in IntRange.
  • "The maximum must be at least the minimum" is a rule across fields, so it belongs in ValidateConfiguration.
  • "This item code must exist in the live catalog" is checked at runtime, not in the schema.

When a promo is evaluated, the base class checks only that the saved JSON fits Config: required properties are present, values have the right types, and union discriminators are declared cases. It doesn't re-check range, length, pattern, or item constraints, so a bound that matters for correctness also belongs in ValidateConfiguration.

Model The Domain

A flat record of loosely related optional fields lets the configuration represent combinations that make no business sense. This flat discount configuration can hold contradictory values, and the management app has to show fields that mean nothing in some modes:

{
    "discountKind": "fixed",
    "amount": 5.00,
    "percentage": 10
}

Prefer these modeling moves instead:

  1. Use nested objects when several fields belong to one real sub-concept.
  2. Use PromoUnion on an abstract config base when one concept has distinct valid cases.
  3. Use PromoUnionCase with stable discriminator values so each case has only the fields that actually make sense.
  4. Reach for SchemaOverride only after the natural object or union model still fails to describe the configuration.

A nested object keeps the products and their quantity together as one concept instead of scattering them across the top level:

[PromoSchema(typeof(MyQualification), "Requires a minimum combined quantity of selected products.")]
public record Config
{
    public required ProductQuantity Requirement { get; init; }

    public record ProductQuantity
    {
        [SchemaTitle("Products")]
        [SchemaFormat(SchemaFormats.ItemCodeLookup)]
        [SchemaMinItems(1)]
        public required IReadOnlyList<string> ItemCodes { get; init; }

        [SchemaTitle("Minimum quantity")]
        [IntRange(Min = 1)]
        public int MinimumQuantity { get; init; } = 1;
    }
}

A discriminated union gives each mode only its own fields, so a fixed amount and a percent can't be set together:

[PromoUnion("type")]
[PromoUnionCase(typeof(FixedDiscount), "fixed")]
[PromoUnionCase(typeof(PercentageDiscount), "percentage")]
public abstract record DiscountMode;

public sealed record FixedDiscount : DiscountMode
{
    [SchemaTitle("Amount")]
    [SchemaFormat(SchemaFormats.Currency)]
    public decimal Amount { get; init; }
}

public sealed record PercentageDiscount : DiscountMode
{
    [SchemaTitle("Percent")]
    [SchemaFormat(SchemaFormats.Percentage)]
    public decimal Percent { get; init; }
}

[PromoSchema(typeof(MyReward), "Applies a discount using one supported mode.")]
public record Config
{
    public required DiscountMode Discount { get; init; }
}

The generated schema for a union carries oneOf, x-discriminator, and each case's discriminator value, so the management app can show a case-based editor instead of one oversized form. At runtime, a configuration whose discriminator value isn't a declared case is rejected. Keep discriminator values stable: renaming one invalidates every saved configuration that uses it.

The built-in OrderSubtotalDiscount reward models its benefit this way. This is the configuration of the Holiday Savings promo's reward as of Version 2: 20% off, with the $50 cap on the total discount that Version 2 adds:

{
    "benefit": {
        "type": "Percentage",
        "rate": { "type": "Configured", "percent": 20 },
        "maximumSavings": { "type": "UpTo", "amount": 50 }
    }
}

A configuration type can't contain itself, directly or through another type.

Extension Checklist

  • The extension is the right kind: a qualification for eligibility, a limit for continued use, a reward for changes to the order.
  • The class is public, not abstract, and in the same assembly as your company app's order plan.
  • The class name, namespace, and ExtensionName are the ones you intend to keep.
  • The constructor asks only for services the host provides.
  • Config has PromoSchema, and a reward class has RewardTypeSchema with the classification that matches when it must run.
  • Every setting a promo author fills in has a clear title and description, and lookup values use the right SchemaFormat.
  • Simple bounds are annotations; bounds that matter for correctness are also checked in ValidateConfiguration; checks that depend on live data happen at runtime.
  • Nested objects or a PromoUnion rule out combinations that make no sense, instead of a flat record of optional fields.
  • SchemaOverride is used only when nothing else can describe the shape.
  • Qualifications and limits return at least one outcome.
  • A reward changes the order only through the effect helpers and calls RecordRewardApplication after it applies.

Renaming And New Company App Builds

Each qualification, limit, and reward saved on a promo records its extension's assembly name and full type name. That pair is matched against the live company app build whenever a promo is evaluated, and when a promo that isn't running is opened in the management app; a running promo is shown with the build it was deployed with. Promo versions are kept separately from the extensions, so a promo's history does not vanish when a new company app build goes live.

When a running promo is evaluated and its extension type was renamed, moved, or removed, it can't load that type: its qualifications and limits that use that type are reported as extension errors, which count as not satisfied, so the promo stops applying, and its rewards that use that type are skipped.

Saved settings must also still fit Config. Adding a required property, making an existing one required, renaming a required property, changing a property to a type its saved values don't fit, or renaming a union discriminator value makes saved configurations fail to parse, with the same result. A saved setting whose property was renamed or removed is ignored, so a renamed optional property silently takes its default value.

Build compatibility caution

Treat renaming, moving, or reclassifying an extension, and renaming or tightening its settings, as changes that affect saved promos rather than as harmless refactors. When the new company app build goes live, affected promos may need to be re-authored or manually adapted.

For what promo authors see and do after a build change, see After A New Company App Build in the Authoring And Management Guide and After A New Company App Build in the Rewards guide.

Troubleshooting

Symptom Likely cause Fix
The management app doesn't offer the extension Config has no PromoSchema, or the company app build that contains the extension hasn't gone live Add PromoSchema to Config. The extension appears once a build that contains it goes live
The build fails with a PSCHEMA error An annotation doesn't fit its property, or the extension is missing required metadata Look up the ID in Build-Time Checks
The promo never applies, and the insights response shows a CriterionExtensionError The extension couldn't be found, created, or run: the class isn't public and concrete or isn't in the order plan's assembly, the constructor asks for a service the host doesn't provide, the saved configuration doesn't parse or validate, your code throws, or the class was renamed or moved The error's message names the problem. See When An Extension Fails and Renaming And New Company App Builds
A qualification passes every order It returns an empty outcome list, which counts as satisfied Return at least one outcome; see Outcomes, Not Booleans
A setting takes its default value after a new build The property was renamed, so the saved value is ignored Restore the original property name; see Renaming And New Company App Builds
A reward's changes aren't reported to callers or reset on the next calculation The reward changed the order without the effect helpers Use the effect helpers; see Record What The Reward Changes
A per-order limit never stops a reward The reward doesn't call RecordRewardApplication Call it after the reward applies
A reward runs at the wrong point in the calculation Its RewardTypeSchema has the wrong classification Choose the classification from When A Reward Runs
A limit can't be attached where you expect Its base class doesn't allow that placement Change the base class; see Base Classes

Testing An Extension

  • Call the public method with raw JSON configuration, so parsing and validation run as they do in production: EvaluateAsync for a qualification, EvaluatePromotionLimitsAsync or EvaluateRewardLimitsAsync for a limit, and ApplyAsync for a reward.
  • Build a reward's starting measurements with OrderMeasurementCollector.Collect(order), from the Exigo.OrderCalc.Implementation.Promotions namespace.
  • Give a reward a fake promo data service that implements both IAsyncPromoData and IPromoApplicationResultRecorder, pass it as the reward's IAsyncPromoData constructor argument, and assert on the effects it records. The effect helpers and RecordRewardApplication throw when the reward's promo data isn't a recorder.
  • Inspect the generated schema the build writes for each configuration (one .extension.json file per PromoSchema model) to see exactly what the management app will render.

Continue Reading