Skip to content

Powered by Grav

Coming soon — KahunaCart is in final testing. Join the list and be first to know. Join the list

Sales

This page shows a store administrator how to run an automatic discount. A sale needs no code, applies to every shopper, runs on a schedule, and changes what the catalog says a product costs: the regular price struck through, the sale price beside it, and a badge naming the campaign on the grid, on the product page and in the buy button.

A coupon is a code a customer types. A sale is a price the store is charging. The two work together, and the rules for that are in Sales and coupons together.

Before you begin

  • The kahunacart.sales.manage permission on whoever runs promotions. It is separate from kahunacart.settings, so the person who runs a campaign does not need access to payment credentials or tax zones.
  • At least one product to discount. See Products and catalog.

What a sale is

A sale is a header and a set of rules. The header is what you name, schedule and switch off.

Field Admin label What it does
name Name Your handle for the campaign — "Summer clearance". Never shown to a customer.
label Badge What a customer reads: the badge on a card and the discount line on the receipt. Translatable.
enabled Enabled Off means the campaign does nothing, whatever its dates say.
starts_at Starts UTC epoch seconds. Blank means "since always".
expires_at Ends UTC epoch seconds. Blank means "until you switch it off".
priority Priority Higher wins when two sales cover the same variant.
stack_with_coupons Coupon codes still work on discounted lines Whether a customer may use a code on top of this sale. On by default.

The rules are what the sale does. Each names a scope and a discount.

Field Admin column What it does
scope Scope all, category, tag, product or variant, listed as Everything, Category, Tag, Product and Variant.
scope_id Applies to What the rule points at. Null, and only null, for all.
mode Discount percent or fixed, listed as Percent off and Fixed amount off.
percent Value A decimal string — '20', '12.5'. Never a float.
amount_minor Value A fixed amount off per unit, in minor units. Over the API it is sent as amount, a decimal string.
exclude Exclude This rule takes things out of the sale rather than discounting them.
position Row order The order the rules are listed in, which breaks ties.

"20% off Clothing, but 30% off the boardshort, and not the socks" is one sale with three rules.

The admin shows one of four states per sale.

Status Means
Active Enabled and inside its window. Discounting right now.
Scheduled Enabled, but the start date has not arrived.
Expired Enabled, but the end date has passed.
Off Enabled is unticked.

How a discount is decided

Three questions, asked in this order, for every variant on every page.

1. Which sale?

Sales are considered highest priority first, oldest first after that. On the default setting the first sale that covers the variant wins and the rest are not looked at.

YAML
# user/config/plugins/kahunacart.yaml
sales:
  overlap: priority       # priority | best_for_customer

Set sales.overlap to best_for_customer to evaluate every covering sale instead and take the largest discount, with priority breaking a tie. Use it when your campaigns are independent offers.

2. Which rule inside it?

The most specific rule wins:

TXT
variant  >  product  >  tag / category  >  all

Tag and category are the same specificity, and a tie between them goes to whichever rule is listed first.

A category rule reaches the whole subtree. Putting Clothing on sale discounts everything filed under Clothing → Mens → Tops as well, so the sale covers exactly the products the category page shows.

3. Is it excluded?

An exclusion beats everything. If any rule of a sale marked Exclude matches the variant, that sale does not cover it — including when a narrower rule would otherwise have won.

An exclusion only silences its own sale. A product excluded from the 30% campaign can still be covered by the 10% one running beside it.

The arithmetic

Every amount is computed in minor units. Percentages go through the same single-rounding path coupons and tax rates use, so a 12.5% sale and a 12.5% coupon take the same number of cents off the same line. A fixed amount is capped at what the variant costs: a $10-off rule makes a $6 product free rather than turning it into a $4 refund. The sale price never goes below zero.

Note

A rule that works out to nothing off is not a sale. A percentage that rounds to zero on a very cheap line leaves the product at its ordinary price, with no badge and no strike-through.

Create a sale

  1. In the admin sidebar, click KahunaCart, then Sales.
  2. Click New sale.
  3. Enter a Name for yourself and a Badge for your customers. The badge ends up on receipts, so write it as a customer would read it: "Summer Sale", not "summer-2027-clothing-v2".
  4. Set Priority if another sale may cover the same products. Leave it at 0 otherwise.
  5. Leave Enabled ticked, and leave Coupon codes still work on discounted lines ticked unless this campaign should block codes.
  6. Under Rules, click Add rule. Choose a Scope, pick what it Applies to, choose a Discount of Percent off or Fixed amount off, and enter the Value. Tick Exclude to take something back out of the sale instead.
  7. Add the rest of the rules, broadest first, and use the arrows to order them. Order matters only for ties.
  8. Click Create sale.

The storefront picks the sale up on the next page view. There is no cache to clear and no reprice job to wait for.

Important

Saving replaces the sale's rules with what is in the table. A sale with no rules discounts nothing.

A blank name is refused with "A sale name is required." and a blank badge with "A sale needs a badge for shoppers to see."

Schedule a sale

  1. Open the sale in KahunaCart → Sales.
  2. Under Schedule, set Starts, Ends, or both.
  3. Click Save sale.

Leave Starts blank to begin immediately. Leave Ends blank for a campaign you will end by hand. Clearing a date really does clear the column, so a sale with both fields blank runs until you untick Enabled.

To pause a campaign without losing its rules, use Disable in the row's actions menu. That sends the enabled field on its own and leaves the rules standing.

Sales in the cart and at checkout

The cart line keeps the regular price. Nothing rewrites unit_price_minor, so a receipt can show both numbers. The discount arrives as one adjustment row per covered line, carrying the item id, exactly as a fixed_product coupon does. The adjustment's source records the sale id, the rule id, the mode, the rule's own number, the base it was computed on, the per-unit discount and whether coupons may stack.

Live rules, frozen price. Whether a line is covered is read from the catalog as it stands now, so filing a product into a discounted category prices the next recalculation. How much comes off is worked out against the line's own unit_price_minor, the number the customer was quoted. Raise a $50 product to $100 with a 20% campaign running and the line still gets $10 off.

A sale ending mid-cart corrects itself. Carts recalculate on every change, so adding an item, changing a quantity or submitting the checkout reprices the whole cart at the ordinary price. There is no reprice job and nothing to clean up. The cart's "estimate shipping & tax" box runs the same stage the checkout runs, so an estimate asked after a campaign ended prices the cart the way the checkout is about to.

Discounts are capped at the order's headroom — items plus whatever adjustments have already landed — so a sale can take a cart to zero and never past it.

Sales and coupons together

A coupon comes off the sale price, not the list price. 20% off a $50 shirt that is already half price is $5, not $10.

Every base the coupon stage measures is the line's total minus whatever a sale has taken off it. That applies to all three coupon types: a percentage, a cart-wide fixed amount, and a per-line fixed amount, which is capped at the discounted line rather than the original. The cart-wide type is measured over the same lines as the other two, not over the whole cart. A fixed_cart code writes one order-level adjustment, but the money comes off the lines underneath it, so a $60 code on a cart holding $10 of ordinary goods beside $100 of stock a non-stacking sale claimed takes $10.

When Coupon codes still work on discounted lines is unticked, the lines that sale covers are removed from the coupon's reach entirely. A coupon with other lines to work on applies to those. A coupon left with nothing is refused with "That code can't be combined with the current sale.", flashed on the cart like an expired code. The sale itself is unaffected.

Sales on the storefront

The badge and the strike-through

A product a sale covers renders its regular price inside <s class="kahunacart-compare-price"> with the sale price beside it, plus a .kahunacart-sale-badge element carrying the campaign's badge text. It falls back to PLUGIN_KAHUNACART.SALE_BADGE ("On sale") for a campaign with no badge of its own.

Important

While a sale covers a variant, compare_price_minor is ignored. The strike-through shows the regular price — what a shopper would pay today if the campaign were not running.

The option picker

Sales are per variant, so the picker swaps three things together when a shopper changes an axis: the price, the struck-through regular price, and the badge. Each of those strings is formatted in PHP in the site's locale and shipped down with the page. A variant no campaign covers puts all three back.

Structured data

The JSON-LD Offer quotes the sale price, and an AggregateOffer spans the discounted range, so the markup matches the number on the page.

The on-sale filter

?sale=1 narrows a listing to the products a campaign covers. The checkbox appears in the filter panel only while a sale is running, the same presence rule every other facet follows.

  • The price boxes keep measuring the regular price. min and max filter on what a product ordinarily costs, discounted or not, so a bookmarked price range means the same thing every week.
  • A catalog-wide sale adds no constraint, because everything qualifies. If such a sale carries exclusions, the excluded products are subtracted — up to about five thousand published products, past which the facet stops constraining and lists a handful of extras. The same cap applies to any list the facet arrives at, a category rule over the shop's largest category included. Cards that slip through carry no badge.
  • A variant exclusion does not take its product out of the listing. A product with one size left out of a sale still has something discounted in it. A product whose every variant is excluded does come off the list.

Caching

An anonymous catalog page keeps whatever system.pages.expires says, capped at the next moment a sale starts or stops. A store with no campaigns scheduled has no boundary ahead, so the configured lifetime stands.

Twig reference

Every product and variant the storefront hands a template carries a sale key: null at the ordinary price, otherwise an array. A theme building its own grid can ask directly.

TWIG
{% set offer = kahunacart_sale(variant, product) %}
{% if offer %}
  <s>{{ kahunacart_price(offer.regular_minor) }}</s>
  <span>{{ kahunacart_price(offer.sale_minor) }}</span>
  <span class="kahunacart-sale-badge">{{ offer.label }}</span>
{% endif %}
Key What it holds
sale_id The sale that decided the price.
rule_id The rule inside it that won.
label The badge text, already translated for the language being browsed.
regular_minor The catalog price, in minor units.
sale_minor The discounted price, in minor units.
discount_minor The difference, per unit. Multiplying by quantity is the cart's job.
mode percent or fixed.
value The rule's own number as text.
stack_with_coupons Whether a code may be applied on top.

The rules are loaded once per request and every later question is answered from memory, so a grid of fifty cards costs what a single product page costs.

Translate the label

The badge is translatable the same way a shipping method's or a tax rate's label is. Open the sale, use the language tabs above the header fields, and fill in the badge for each language. An empty field falls back to the base. The name is never translated.

A receipt is different: a sale's line on an order is resolved against the language the order was shopped in, not the language of whoever is looking at it. A webhook completing an order and a merchant recalculating one from the admin both produce the words the customer read when they bought. See Multilanguage.

The storefront resolves the badge against the language being browsed, falling back to the site default and then to the stored badge.

Import and export

Sales travel in the catalog YAML, merged by name.

YAML
sales:
  - name: Summer Sale
    label: Summer Sale
    priority: 10
    rules:
      - scope: category
        category: clothing
        mode: percent
        percent: '20'
      - scope: product
        product: sandbar-boardshort
        mode: percent
        percent: '30'
      - scope: product
        product: saltwater-socks
        exclude: true

Every rule names its target by slug (category, product), by label (tag), or by its product's slug and its own SKU (variant), never by a row id. A rule naming something the receiving store does not have is dropped rather than fatal. Coupons stay out of the file, because a code is issued to a person and its redemption log is customer data.

The demo seed ships one campaign — 20% off Clothing, 30% on the boardshort, socks excluded — so bin/plugin kahunacart seed gives you something to look at. seed --purge removes it again by name. See Products and catalog.

API reference

All sale endpoints require kahunacart.sales.manage, not kahunacart.settings.

TXT
GET    /kahunacart/sales
POST   /kahunacart/sales           # {name, label, ...header, rules: [...], translations: {...}}
GET    /kahunacart/sales/{id}
PATCH  /kahunacart/sales/{id}      # header fields, rules, translations — only what is sent is touched
DELETE /kahunacart/sales/{id}

A sale is always read and written with its rules. name and label are both required on create and neither may be blank. The header also takes starts_at, expires_at, priority, enabled and stack_with_coupons. The two dates are nullable, so clearing one clears the column rather than writing a zero.

A rule object takes scope, scope_id, mode, percent, amount and exclude. percent is a decimal string. amount is a decimal string in the store currency, stored in minor units, and a rule comes back carrying both amount_minor and the decimal amount. position is taken from the row's place in the list, not from anything the client sends.

PATCH with no rules key updates the header and leaves the rules alone, which is what the enable toggle sends. PATCH with a rules array replaces the whole set inside one transaction, an empty list included — that is how the last rule is removed. There is no "add a rule" call.

A scope nobody can resolve, a mode nobody implements, a percentage outside 0–100, or an amount that is not a decimal number is refused with a 422 carrying the reason. Deleting a sale removes its rules and its label's translations, and prices go back to what the catalog says.

See REST API for authentication and conventions, and Run the store from an AI assistant for the same five operations as assistant tools.