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 intoConfig, validates them, and calls yourEvaluateAsyncwith the order, the promo, and the typed settings.EvaluateAsyncreturns criterion outcomes rather than true or false. The qualification passes only when all of its outcomes are satisfied.ExtensionNameis the name evaluation reports use. Promo authors see the class name.PromoSchematiesConfigto the extension and describes it. The annotations onMaximumWeightgive 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.
ExtensionNameis what evaluation reports use: theextensionfield 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:
- Receives the saved configuration as a JSON string.
- Parses it into
TConfig. - Calls
ValidateConfiguration. - 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
RewardLimitExtensionBasemakes it a reward limit only. UsePromotionLimitExtensionBaseorDualScopeLimitExtensionBasefor the other placements. IPromoUsageRecorderis an extra constructor service.GetRewardApplicationCountAsynccounts the reward'sRecordRewardApplicationcalls for the order, so the limit counts applications, not units.- A
Quotaoutcome 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:
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, andTime, - standard validation formats such as
UriandEmail, - and display-hint formats such as
Switch,Color,Multiline,RadioGroup, andSegmented.
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:
Prefer these modeling moves instead:
- Use nested objects when several fields belong to one real sub-concept.
- Use
PromoUnionon an abstract config base when one concept has distinct valid cases. - Use
PromoUnionCasewith stable discriminator values so each case has only the fields that actually make sense. - Reach for
SchemaOverrideonly 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
ExtensionNameare the ones you intend to keep. - The constructor asks only for services the host provides.
ConfighasPromoSchema, and a reward class hasRewardTypeSchemawith 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
PromoUnionrule out combinations that make no sense, instead of a flat record of optional fields. SchemaOverrideis 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
RecordRewardApplicationafter 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:
EvaluateAsyncfor a qualification,EvaluatePromotionLimitsAsyncorEvaluateRewardLimitsAsyncfor a limit, andApplyAsyncfor a reward. - Build a reward's starting measurements with
OrderMeasurementCollector.Collect(order), from theExigo.OrderCalc.Implementation.Promotionsnamespace. - Give a reward a fake promo data service that implements both
IAsyncPromoDataandIPromoApplicationResultRecorder, pass it as the reward'sIAsyncPromoDataconstructor argument, and assert on the effects it records. The effect helpers andRecordRewardApplicationthrow when the reward's promo data isn't a recorder. - Inspect the generated schema the build writes for each configuration (one
.extension.jsonfile perPromoSchemamodel) to see exactly what the management app will render.
Continue Reading¶
- For checking promos from your own systems, continue to the OrderCalc Promo API Guide.
- For saving and deploying the promos that use your extensions, see Saving, Versions, And Deploying.
- For what promo authors see when a new company app build goes live, see After A New Company App Build.
- For what the built-in rewards can do, see OrderCalc Promo Rewards.