Skip to content

Order Promotions

Promotions are dynamic, component-based calculations attached to orders. Each promotion runs a Filtrera script in a pure runtime and can produce discount effects (percentage or absolute adjustments) and promotional messages (customer-facing text). Promotions re-evaluate automatically whenever the order changes.

TIP

For simple fixed-value deductions that don't need calculation logic, see Discounts.

How Promotions Work

When an order changes — items added, quantities updated, delivery methods changed — every promotion on that order is re-evaluated:

  1. The promotion component executes with the current order state
  2. The component returns discount effects and/or message effects
  3. Discount effects are applied as calculated discounts on order lines and shipping
  4. Message effects are stored on the promotion for display to the customer

This happens automatically. No manual triggering is required.

Promotion Components

Promotion logic is written in Filtrera using the promotion runtime — a pure, side-effect-free environment. Components use the .hpromo or .hpr file extension.

The order Symbol

Every promotion component has access to the order symbol, which represents the current state of the entire order including deliveries, order lines, and shipping.

Discount Effects

A promotion can return discount effects using three built-in helpers:

percentage(target, rate)

Apply a percentage discount:

filtrera
// 10% off entire order
from percentage(order, 10%)

// 100% off shipping (free shipping)
from percentage(target(e => e is Delivery), 100%)

// 20% off specific product category
from percentage(target(e => e is OrderLine and e.dynamic->'category' == 'electronics'), 20%)

absolute(target, amount)

Apply a fixed amount discount:

filtrera
// 50 off entire order
from absolute(order, 50)

// 10 off each qualifying line
from absolute(target(e => e is OrderLine and e.quantity > 5), 10)

target(filter)

Select which parts of the order to discount:

filtrera
// Target deliveries
target(e => e is Delivery)

// Target specific order lines
target(e => e is OrderLine and e.productNumber == 'SKU-123')

Returning Nothing

If a promotion should have no effect for a given order state, return nothing:

filtrera
from order.total < 100 match
  true |> nothing
  false |> percentage(order, 5%)

Message Effects

Beyond discounts, promotions can return messages — customer-facing text that communicates promotional status. Messages are useful for showing progress toward a threshold, confirming that a promotion is active, or upselling related offers.

Basic Message

A message effect is a record with type = 'message' and a message field:

filtrera
from {
  type = 'message'
  message = 'Free shipping applied!'
}

Message Placeholders and Fields

Messages support a placeholder syntax using {key} in the message text, paired with a fields record that provides the actual values:

filtrera
from {
  type = 'message'
  message = 'Add {remaining} more for free shipping!'
  fields = { remaining = threshold - order.total }
}

This produces three stored values on the promotion:

FieldValuePurpose
messageTemplate"Add {remaining} more for free shipping!"The raw template with placeholders intact
messageFields{ remaining: 150 }The structured field values
messageRendered"Add 150 more for free shipping!"Server-rendered version with placeholders replaced

The placeholder approach gives consumers flexibility:

  • Simple clients can display messageRendered directly
  • Rich clients (e.g., storefronts) can use messageTemplate + messageFields to render the message with custom formatting — for example, styling the {remaining} value in bold or a different color

Message Type

Each message has a messageType that identifies the kind of message. This is useful for consumers to decide how to display or filter messages without comparing raw strings.

You can set it explicitly:

filtrera
from {
  type = 'message'
  messageType = 'free-shipping-progress'
  message = 'Add {remaining} more for free shipping!'
  fields = { remaining = threshold - order.total }
}

If messageType is omitted, one is automatically generated by hashing the message template. This means messages with the same template text will share the same type, while different templates get different types.

Combining Discount and Message Effects

A promotion component can return both discount effects and message effects as a tuple:

filtrera
// free-shipping-with-message.hpromo
param threshold: number

from order.total >= threshold match
  true |>
    from percentage(target(e => e is Delivery), 100%)
    from { type = 'message', message = 'You qualify for free shipping!' }
  false |>
    from {
      type = 'message'
      message = 'Add {remaining} more for free shipping!'
      fields = { remaining = threshold - order.total }
    }

When the order total meets the threshold, the customer gets free shipping and a confirmation message. Below the threshold, they see a progress message with the remaining amount — no discount applied.

Promotion Groups and Combination Rules

Every promotion belongs to a promotion group — a string that categorizes the promotion (e.g., 'shipping', 'loyalty', 'campaign'). Groups control how promotions interact when multiple are active on the same order.

canOnlyCombineWith

Restrict a promotion to only work alongside specific groups. If any other group is active, this promotion is excluded:

json
{
  "type": "createPromotion",
  "componentId": "exclusive-deal",
  "promotionGroup": "exclusive",
  "canOnlyCombineWith": ["loyalty"],
  "description": "Exclusive Deal"
}

This promotion only stays active if the only other active promotions belong to the loyalty group.

canNotCombineWith

Exclude a promotion when specific groups are active:

json
{
  "type": "createPromotion",
  "componentId": "free-shipping",
  "promotionGroup": "shipping",
  "canNotCombineWith": ["exclusive"],
  "description": "Free Shipping"
}

This promotion is excluded if any promotion in the exclusive group is active.

How Combination Rules Are Applied

After all promotions evaluate, the system checks combination rules:

  1. Each active promotion's rules are tested against other active promotions' groups
  2. Promotions that violate their rules are marked as inactive (isActive = false)
  3. Inactive promotions contribute no discount effects or messages
  4. The order totals are calculated using only the surviving active promotions

Creating Promotions

Promotions are added to orders via commands, typically emitted from rules.

Via Component Reference

The most common approach. The component is a deployed .hpromo resource:

json
{
  "type": "createPromotion",
  "componentId": "free-shipping",
  "promotionGroup": "shipping",
  "description": "Free Shipping on Orders Over 500",
  "parameters": {
    "threshold": "500"
  }
}

See createPromotion command reference.

Via Inline Source

For dynamic or one-off promotions, embed the Filtrera source directly:

json
{
  "type": "createPromotionBySource",
  "source": "from percentage(order, 10%)",
  "promotionGroup": "campaign",
  "description": "10% Welcome Discount"
}

See createPromotionBySource command reference.

Common Pattern: Rule-Based Promotions

The typical approach is to use a rule to add promotions to orders when certain conditions are met, then let the promotion component handle the ongoing calculation.

Example: Time-Limited Free Shipping

1. Create a reusable promotion component:

filtrera
// free-shipping.hpromo
param threshold: number

from order.total >= threshold match
  true |>
    from percentage(target(e => e is Delivery), 100%)
    from { type = 'message', message = 'Free shipping applied!' }
  false |>
    from {
      type = 'message'
      message = 'Spend {remaining} more for free shipping'
      fields = { remaining = threshold - order.total }
    }

2. Create a rule to add the promotion:

filtrera
// black-friday.hrule
param input: OnOrderCreated

from [{
  effect = 'orderCommand'
  type = 'createPromotion'
  componentId = 'free-shipping'
  promotionGroup = 'shipping'
  description = 'Black Friday Free Shipping'
  parameters = { threshold = '500' }
}]

3. Deploy via manifest with activation dates:

yaml
uri: /resources/components/free-shipping.hpromo
spec:
  codeFile: free-shipping.hpromo

---

uri: /resources/rules/black-friday
spec:
  codeFile: black-friday.hrule
  activeFrom: 2025-11-29T00:00:00Z
  activeTo: 2025-11-29T23:59:59Z

The activeFrom and activeTo fields on the rule entity control when the rule fires. The promotion component itself is stateless and reusable — timing is configured at the rule level.

How It Plays Out

During the campaign (Nov 29):

  • OnOrderCreated rule fires for each new order (within the active window)
  • Promotion is added with the free-shipping component and threshold = 500
  • As the customer adds items, the promotion re-evaluates
  • Below threshold: message says "Spend X more for free shipping"
  • Above threshold: free shipping is applied with a confirmation message

After the campaign ends (Nov 30+):

  • The rule no longer fires for new orders
  • Existing orders keep their promotion — it continues to work as the order is modified
  • If the customer removes items and drops below threshold, the discount disappears but the message updates

Managing Promotions

Updating Parameters

Change the runtime parameters of an existing promotion:

json
{
  "type": "setPromotionParameters",
  "promotionId": "65812410-bd14-4d6d-ab79-374789a976c1",
  "parameters": { "threshold": "750" }
}

See setPromotionParameters.

Updating Description

json
{
  "type": "setPromotionDescription",
  "promotionId": "65812410-bd14-4d6d-ab79-374789a976c1",
  "description": "Updated Campaign Name"
}

See setPromotionDescription.

Dynamic Fields

Store custom metadata on a promotion:

json
{
  "type": "setPromotionDynamicFields",
  "promotionId": "65812410-bd14-4d6d-ab79-374789a976c1",
  "fields": { "campaignId": "BF2025", "source": "email" }
}

See setPromotionDynamicFields.

Deleting

json
{
  "type": "deletePromotion",
  "promotionId": "65812410-bd14-4d6d-ab79-374789a976c1"
}

See deletePromotion.

Pure Runtime Constraints

Promotion components run in a pure runtime with strict restrictions. This ensures promotions evaluate quickly and safely without unintended side effects.

What You CAN Do

  • ✅ Access the current order via the order symbol
  • ✅ Access custom parameters passed to the component
  • ✅ Perform calculations and logic
  • ✅ Use pattern matching and filtering
  • ✅ Return discount effects (percentage, absolute, target)
  • ✅ Return message effects

What You CANNOT Do

  • ❌ Send messages to actors
  • ❌ Send emails
  • ❌ Query the graph
  • ❌ Access the registry
  • ❌ Schedule jobs
  • ❌ Modify external state

Error Handling

If a promotion component fails to evaluate — due to a parse error, invalid parameters, or runtime exception — the error is captured on the promotion entity:

  • error.code — error classification (e.g., PARSE_ERROR, INVALID_PARAMETER, INTERNAL_ERROR)
  • error.message — human-readable description

Errored promotions are marked as inactive and contribute no effects. The rest of the order's promotions continue to function normally.

Promotion Entity Fields

FieldDescription
promotionIdUnique identifier
componentReference to the component (componentId, version)
parametersKey-value pairs passed to the component
descriptionCustomer-facing text, copied to invoices
promotionGroupGroup for combination rules
canOnlyCombineWithGroups this promotion can coexist with
canNotCombineWithGroups that exclude this promotion
isActiveWhether the promotion currently produces effects
messageTypeType identifier for the current message (explicit or auto-hashed)
messageTemplateThe raw message text with {placeholder} syntax
messageRenderedServer-rendered message with placeholders replaced
messageFieldsStructured field values for client-side rendering
calculatedTotalTotal discount amount this promotion contributes
errorError details if evaluation failed
dynamicCustom metadata
createdAtCreation timestamp

Querying Promotions

Promotions are available via the Graph:

Advanced Example: Tiered Volume Discount with Message

filtrera
// volume-discount.hpromo

let rate = order.total match
  when order.total >= 10000 |> 15%
  when order.total >= 5000 |> 10%
  when order.total >= 1000 |> 5%
  |> 0%

let nextTier = order.total match
  when order.total >= 10000 |> nothing
  when order.total >= 5000 |> { target = 10000, rate = '15%' }
  when order.total >= 1000 |> { target = 5000, rate = '10%' }
  |> { target = 1000, rate = '5%' }

from rate > 0% match
  true |> percentage(order, rate)

from nextTier match
  nothing |>
    { type = 'message', message = "You're at our best tier — 15% off!" }
  |>
    {
      type = 'message'
      message = 'Spend {remaining} more to unlock {nextRate} off'
      fields = {
        remaining = nextTier.target - order.total
        nextRate = nextTier.rate
      }
    }

This promotion:

  • Applies 5%/10%/15% off based on order value
  • Shows a message about the next available tier using placeholders
  • Re-calculates automatically as the order changes

Best Practices

Use Generic Components with Parameters

Create reusable promotion components and configure them via parameters:

filtrera
// free-shipping.hpromo — reusable across campaigns
param threshold: number

from order.total >= threshold match
  false |> nothing
  true |> percentage(target(e => e is Delivery), 100%)

Rules configure the specifics:

filtrera
param input: OnOrderCreated

from [{
  effect = 'orderCommand'
  type = 'createPromotion'
  componentId = 'free-shipping'
  promotionGroup = 'shipping'
  description = 'Summer Free Shipping'
  parameters = { threshold = '300' }
}]

Use Promotion Groups Intentionally

Group promotions by business category so combination rules are meaningful:

  • 'shipping' — free shipping promotions
  • 'campaign' — seasonal campaigns
  • 'loyalty' — member benefits
  • 'coupon' — one-time coupon codes

Use Messages for Customer Communication

Messages are rendered and stored on the promotion, making them available through the Graph for display in storefronts, portals, and emails. Use placeholders with fields when you want rich clients to render values with custom formatting:

filtrera
// Placeholder approach — allows rich rendering
from {
  type = 'message'
  message = 'You saved {amount} on this order!'
  fields = { amount = order.total * rate }
}

Use Clear Descriptions

The description field appears on invoices and customer communications:

  • "Free Shipping on Orders Over 500", "10% Volume Discount", "Loyalty Member Benefit"
  • "Discount", "Promo", "Test"

See Also

© 2024 Hantera AB. All rights reserved.