Skip to content

Powered by Grav CMS + Helios Theme

Rules and conditions

The rules engine decides two things about a discount: what it does, which is an operation, and whether it applies at all, which is a set of conditions. This page is for a store administrator writing a campaign, and for a developer adding an operation or an attribute of their own.

Sales are what the engine drives. Price lists, payment methods, checkout fields and coupons take the same conditions. See Sales for the campaigns themselves.

Before you begin

  • The kahunacart.sales.manage permission. Both API routes on this page check it, and so does the Sales editor.
  • At least one sale to attach a rule to. See Create a sale.

Operations

An operation is one thing a rule can do: take a percentage off, take an amount off, run a quantity ladder, run buy-one-get-one, make the cheapest of a set free. Each declares its own code, arguments and label, so the admin renders a form for it.

Six operations ship with KahunaCart. An add-on registers more, and they appear in the Sales editor as soon as the plugin is enabled.

Code Scope What it does Arguments
percent_off line A percentage off every unit the rule covers percent
amount_off line A fixed amount off every unit the rule covers amount
quantity_tiers set Buy 2 save 10%, buy 3 save 15% basis, tiers
bogo set Buy X, get Y at a percentage off buy, get, percent, max_sets
cheapest_free set Buy N, the cheapest comes off min_quantity, discount_count, percent
free_gift set Put a product in the basket, free, while the promotion holds variant, quantity

free_gift changes what is in the basket rather than what it costs. The gift line is written at zero, so the operation returns no adjustment; the cart adds and removes the line. The gift line carries the rule that gave it away, so it is added once, removed the moment the promotion stops applying, and cannot be deleted or retyped by the customer while the promotion runs. See Sales → Free gifts.

A line operation runs once per covered line. A set operation runs once with every line the rule covers, which is what makes buy-one-get-one correct: a cart holding six qualifying units discounts three, not one.

percent_off and amount_off are what a rule's mode and percent/amount fields are shorthand for. A rule naming neither an operation nor arguments is a percent_off or an amount_off rule.

Conditions

A condition is an attribute (what to ask about), an operator (how to compare) and a value (what to compare against). "Cart subtotal is at least 50.00" is one condition.

Conditions live in containers:

Container Matches when
all Every child matches
any At least one child matches
none No child matches

Containers nest, up to three levels. An all holding an any says "the cart is over 50.00 and it holds either a jacket or a coat".

A campaign with no conditions runs unconditionally.

Important

A sale with conditions is priced at checkout, not on the catalog. It shows no badge, no struck-through price and no place in the On sale filter, because a product card has no cart to judge the conditions against. Use conditions when the offer depends on the basket, and leave them off when you want the campaign visible on the shop.

The attribute vocabulary

GET /kahunacart/rules/vocabulary returns the attribute list, the admin's condition builder draws its dropdowns from it, and the MCP tool list_rule_attributes hands it to an AI client.

An attribute KahunaCart does not publish is refused when you save, with the key named in the message.

So is a value that can't be compared. A sale, coupon, payment method, campaign template or checkout field refuses it with a sentence naming the problem, and saves nothing. That covers:

  • a group whose children isn't a list
  • an operator the attribute doesn't take
  • an empty list, or a single value, given to in or not_in
  • text where a number goes, or a true or false where an id or text goes
  • a product, variant, category, tag or customer group that doesn't exist

The last check applies when the condition names the thing itself: eq, ne, in or not_in. customer_group starts_with "tra" is a fair question about group codes, so it saves as typed. Empty text is a real answer for eq and ne on a text attribute, so a checkout field rule like field_billing_company eq "" (the company was left blank) saves too. Every other operator still needs a value. An old condition that points at something you have since deleted is refused the next time you save it, which is your cue to fix it.

Attribute Type About What it is
cart_line_count number cart How many lines the cart holds
cart_product product cart The products of the cart's lines, for "the cart contains this product". Cart (by product) in the admin. A free gift does not count
cart_product_count number cart How many different products the cart holds
cart_quantity number cart Every unit in the cart, quantities summed
cart_subtotal money cart Minor units, before anything comes off
cart_variant variant cart The variants of the cart's lines, for "the cart contains this variant". Cart (by variant) in the admin. A free gift does not count
customer_email text customer The address on the order, for a signed-in customer
customer_email_domain text customer The part after the @, lower-cased, for a signed-in customer
customer_group text customer The code of the customer's group, or of the store default; empty where they are in neither
customer_order_count number customer Orders this customer has already placed: completed or paid, not refunded, and not a cart, draft, cancelled or test order. A guest has placed none
line_category category line The line's categories, parent categories included
line_is_digital bool line Whether the line is a digital product
line_product product line The line's product
line_quantity number line How many of this line
line_tag tag line The line's tags
line_unit_price money line What one of them costs, in minor units
line_variant variant line The line's variant
order_country text order The shipping country, ISO 3166-1 alpha-2
order_coupon_code text order The codes entered on the order
order_currency text order The currency of the order, ISO 4217: the store's own on a store that sells in one currency, and on one that sells in several the currency the cart or order is in

About says what the attribute is a fact about. A line attribute is answered per line; the others are answered once for the whole basket. A line attribute judged without a line does not match.

Whose customer data a rule reads

A rule about the customer (customer_group, customer_email, customer_email_domain and customer_order_count) reads that customer's own data only when the shopper is signed in as that customer. A guest can type any email address at checkout, and an address proves nothing about who typed it. So a Trade customer signs in to get Trade prices, and an @acme.com price list reaches a customer once they are signed in.

An order your staff built counts the customer they attached. That means a till sale and an order made in the admin, where someone has checked who the customer is.

Anybody else is judged as a guest:

  • They get the store's default group, if there is one, and no other.
  • They have no past orders.
  • They have no email, so customer_email and customer_email_domain match nothing.

The same rule covers price lists, sales, coupons, payment methods, checkout fields and free gifts. A Trade price list and a Trade-only code always agree about who is Trade. To reach a known list of people, give a coupon allowed_emails, which is checked against the address typed at checkout on purpose, or put them in a customer group.

Operators

The operators an attribute offers depend on its type.

Type Operators
number, money eq, ne, gt, gte, lt, lte
text eq, ne, in, not_in, contains, not_contains, starts_with, ends_with
bool eq, ne
select, product, variant, category, tag eq, ne, in, not_in

Money is always compared in minor units. A condition on a subtotal over 50.00 in a two-decimal currency is written as 5000. See Money conventions.

A money condition is in one currency. On a store that sells in more than one currency, cart_subtotal and line_unit_price carry a currency, and a condition with none is in the base currency, which is what every condition written before was. A condition whose currency is not the order's is false, so "cart subtotal at least 5000" in dollars never matches a euro cart, and a euro cart is never judged against a number of dollars. The one exception is a currency set to convert. There, a threshold with no currency, or one that names the base currency, is converted with the current rate and markup and applies too. A condition that names another currency still applies only in that currency. The same goes for a sale, a payment method, a price list rule and a checkout field's required, hidden or validation. The amount is in the minor units of its own currency: 5000 is 50.00 in euro and 5,000 in yen.

To give the same rule in two currencies, write one condition for each and put them in an any container. The sale editor builds that, and shows a currency picker beside the amount on a store with more than one currency. The payment method and price list editors are a flat list that every row must satisfy, so they show the picker but cannot say "either"; write an any group through the API, or make a second method or list.

Some attributes hold several values at once, such as a line's categories or the codes on an order. On those, eq and in both mean "one of them is this", and ne and not_in both mean "none of them is".

"The cart contains". cart_product and cart_variant ask about the whole cart, not the line being priced, so the editors word their operators to match: contains (eq) and contains any of (in) match when any line is one of the values, and doesn't contain (ne) and contains none of (not_in) match when no line is. An add-on's attribute about a set of things in the cart gets the same words. Use Cart (by variant) when the two things you are tying together can be variants of one product, such as a wonky clip and a full-price clip. A line a promotion added, such as a free gift, never counts, so a gift can't keep its own promotion alive. Where a rule is judged with no cart to look at, like a product card or the catalog price, and for a cart holding nothing but gifts, neither wording matches. Sales (and so free gifts), payment methods, price lists and checkout fields all read it the same way.

Choose an operation for a rule

  1. Open KahunaCart → Sales in the admin and edit a sale.
  2. Find the rule in the Rules table.
  3. Choose the operation in the Discount column. Percent off and Amount off keep the fields they always had; anything else shows the arguments that operation declares.
  4. Fill in the arguments.
  5. Click Save.

The rule now prices through that operation, and its adjustment rows record which operation ran and what it was configured with.

The quantity ladder is not in this list. It belongs to the whole campaign and is edited in Buy more, save more. See Quantity tiers.

Write a condition on a sale

  1. Open KahunaCart → Sales in the admin and edit a sale.
  2. In Conditions, choose whether all, any or none of what follows must match.
  3. Click Add condition. Pick an attribute, pick an operator, and fill in the value. is one of and is none of take several values: a multi-select for a category, tag or product, removable chips for a variant, and a comma-separated list for text. You find a variant by typing part of its product name, its own name or its SKU. Price lists and payment methods use the same controls.
  4. Click Add group for a nested container when you need "A and (B or C)".
  5. Click Save.

The campaign now applies only to carts that match, and it no longer shows a badge on the catalog.

Worked example: buy one, get one free

  1. Create a sale and give it a name and a badge.
  2. Add one rule scoped to the tag, category or product the offer covers.
  3. Set the rule's operation to Buy X, get Y.
  4. Set Buy to 1, Get to 1, and the discount on the free ones to 100.
  5. Leave Most times per order at 0 so the cart earns the offer as often as it can.

A cart holding six qualifying units now has the three cheapest free. Setting Most times per order to 1 makes it one free unit per order however many are bought.

Worked example: a discount that needs another product in the cart

The wonky tie clip is £10.00 off, but only while a full-price tie clip is in the cart.

  1. Create a sale and give it a name.
  2. Add one rule scoped to the wonky clip's variant, with the operation Amount off set to 10.00.
  3. In Conditions, leave all chosen and click Add condition.
  4. Pick Cart (by variant), leave the operator on contains, type "clip" in the search box and pick the full-price clip.
  5. Click Save.

A cart with the wonky clip alone pays full price for it. Add the full-price clip and the wonky one drops by £10.00. Take the full-price clip out again and the discount goes with it. Use contains any of to accept any of several clips, and contains none of to run the offer only while none of them is there. If the rule covers more than one product, add a second condition, Variant is the wonky clip, so only that line is discounted.

If you want the two clips sold as a pair at a set price instead, see Bundle price.

What a rule records

Every adjustment a rule writes carries a machine-readable source snapshot, so "why was this 4.20 off?" is answerable after the rule has been edited or deleted.

Key What it holds
engine Which stage wrote the row. sale for a sale.
operation_code Which operation ran
operation_args What it was configured with at the moment it ran
sale_id, rule_id Which campaign, and which of its rules
base_minor What the line charged before anything came off
unit_discount_minor The per-unit discount, on operations that have one
mode, value The older pair, still written for percent_off and amount_off
stack_with_coupons Whether a code was allowed on top
tier_id, tier_count, tier_basis The applied tier, the qualifying count, and whether units or products are counted
condition_ids Which of the campaign's conditions held for this line. Absent on a campaign that has none.

bogo and cheapest_free write buy, get, units_counted, sets and units_discounted instead of a per-unit figure, because the discount is not spread evenly across the units.

rule_id and condition_ids are recorded per line, even where one operation priced several lines at once. A campaign running "20% off Clothing, 30% off the boardshort" under one ladder records the Clothing rule against the shirt and the boardshort rule against the boardshort.

The source_json column is NOT NULL from migration 0030 onward. Rows written before it carry {"legacy": true, "reason": "backfilled"}.

When a campaign cannot be priced

An unknown operation code or condition attribute is refused when you save the sale, with the name in the message.

A campaign that becomes unpriceable later, because the add-on that registered its operation or attribute was disabled, does not take the checkout down. The campaign discounts nothing, and a line naming the sale and the missing operation goes to Grav's error log.

To find one, search the log for kahunacart: sale #, then either re-enable the plugin that provides the operation or edit the sale.

Extend the engine

An add-on registers operations on onKahunaCartRegisterOperations and attributes on onKahunaCartRegisterRuleAttributes. Both are documented with working code in Provider developer guide.

Three constraints apply.

  • An operation returns adjustments and mutates nothing, so two plugins' operations can run in the same recalculation.
  • An operation's arguments are declared as a blueprint, and the admin renders the form from that declaration. A field that is not declared cannot be set.
  • A duplicate operation code or attribute key is refused when the plugin registers, naming the plugin that got there first.

API reference

Both routes need kahunacart.sales.manage, and both are read-only.

Route Returns
GET /kahunacart/rules/vocabulary Every attribute, the containers, the operators and the types
GET /kahunacart/rules/operations Every operation, its scope, and the blueprint of its arguments

The MCP tools list_rule_attributes and list_rule_operations front the same two routes. Call both before writing a sale rule, because an unknown attribute or operation code is refused when the sale is saved.

Rules and conditions are written through the sale that owns them. See Sales → API reference. A sale's rules may carry operation_code and args, and a sale's conditions is one nested document:

JSON
{
  "conditions": {
    "container": "all",
    "children": [
      { "attribute": "cart_subtotal", "operator": "gte", "value": 5000 },
      { "container": "none", "children": [
        { "attribute": "line_category", "operator": "in", "value": [7, 9] }
      ] }
    ]
  }
}

Sending conditions replaces the whole set. A container with no children removes it and puts the campaign back on the catalog. Omitting the key leaves the conditions standing.

Sending conditions replaces the whole set. A container with no children removes it and puts the campaign back on the catalog. Omitting it leaves the conditions standing.

A money condition in another currency says so beside its value, and one in the base currency leaves the key out. Here either 50.00 dollars or 45.00 euro will do:

JSON
{
  "container": "any",
  "children": [
    { "attribute": "cart_subtotal", "operator": "gte", "value": 5000 },
    { "attribute": "cart_subtotal", "operator": "gte", "value": 4500, "currency": "EUR" }
  ]
}

A currency on an attribute that is not money is refused by name when you save. A new money condition must use a currency the store sells in.

Conditions in a disabled currency

A saved payment method, sale or checkout field remains editable when its money condition names a currency that is now disabled. The condition keeps its currency and matches only orders in it. A new condition in that currency still requires it to be enabled. Keeping a currency on one record does not make it available to another.