Skip to content

Powered by Grav CMS + Helios Theme

Coupons

This page shows a store administrator how to create discount codes, restrict them to part of the catalog, and limit how often they are used. A coupon is a code the customer types on the cart page. An automatic discount that needs no code is a sale, and sales are calculated before coupons.

Codes are stored lowercased and trimmed, so lookups are case-insensitive on every database engine. SAVE20, save20 and Save20 are one coupon.

A sale and a coupon are two separate things. A sale is catalog-visible: it changes what the grid, the product page and the buy button say a product costs. A coupon is invisible until the shopper types the code.

Coupon types

Type Admin label What it does
percent Percentage off A percentage off the items subtotal, or off just the restricted lines when the coupon carries restrictions.
fixed_cart Fixed amount off the cart A flat amount off the cart, never more than the cart is worth.
fixed_product Fixed amount off matching products A flat amount off each matching line, capped at that line's own total.

All three are stored as one adjustment per line, each carrying its own share. See Where the discount lands.

A $10-off fixed_product coupon takes $6 off a $6 line and moves on, rather than turning it into a $4 refund. Every discount is also capped at the order's current headroom, which is items plus whatever adjustments have already landed, so a discount never drives a total negative.

The discount basis

A percentage coupon can calculate its discount from either of these two prices.

basis Admin label The percentage comes off Use it for
current Price after sales What the line is worth now, sale included. The default. Almost everything. "20% off" on a shirt already half price is $5, not $10.
list Catalog price The catalog price, so the code stacks on top of a sale. "20% off everything, and yes it works with the summer sale."

current is the default, and every coupon written before this option existed is on it.

The ceiling is the same either way: a line never gives back more than the customer is paying for it. A 90%-of-list code on a line already half price takes the remaining half and stops.

Only percent reads the basis. The admin hides the field for the two fixed types and the API ignores it for them.

The cart and the receipt name the basis where it changes the answer. A code on the list basis says so on the line.

Where the discount lands

A cart-wide discount is one number to the customer and several to the order. A refund of one line out of three, an invoice that prints a per-line total, and a per-line tax each need to know how much of that $10 belonged to this line.

So the division happens once, when the discount is written. Every coupon type stores one adjustment per line, each carrying its own share and its own item_id. The rows of one discount share a group id in their source, and the cart, the receipt and the order detail collapse the group back into the single line the customer agreed to, with a disclosure that opens onto the split.

The split is proportional to what each line contributed to the base, by largest remainder: every line takes the floor of its exact share, and the leftover cents go to the lines with the largest fractional parts. The shares sum to the discount exactly. $10 off three lines worth $0.01, $0.02 and $99.97 is nothing, nothing and $10.00, and a line whose share rounds to zero gets no row.

A refund of one line finds that line's share of the discount already in its own bucket, so the refund allocator needs no knowledge that a coupon exists. See Money conventions.

Note

Tax is computed on the discounted lines. The tax stages run after the coupon and measure a base that already has every discount in it, so a 20% rate on a $100 cart with $20 off charges $16, not $20. See Tax.

Create a coupon

  1. In the admin sidebar, click KahunaCart, then Coupons.
  2. Click New coupon.
  3. Under Discount, enter a Code and choose a Type.
  4. Enter Percent off or Amount off, depending on the type.
  5. On a percentage coupon, choose the Basis. Leave it on Price after sales unless the code is meant to stack on top of a sale.
  6. Leave Enabled on.
  7. Optionally set Starts and Expires under Validity window.
  8. Optionally set Minimum cart total, Maximum cart total, Total uses and Uses per customer under Limits.
  9. Optionally add rows under Restrictions. Leave the list empty for the whole catalog.
  10. Click Create coupon.

The code works on the cart page immediately.

Important

A coupon that is misconfigured for its type is refused with "This coupon is not configured correctly" rather than silently discounting nothing. That covers a percent with no percentage, and a fixed type with no amount or a zero amount.

The restriction matrix

A coupon is limited by a list of restrictions. Each row names one thing and either includes it or excludes it.

Kind Names Covers
product A product Every variant of that product.
variant A variant One size, colour or edition.
category A category That category and everything under it.
tag A tag Every product wearing the tag.

The rules, in the order they settle:

  1. No restrictions is the whole catalog. An empty list covers everything in the cart.
  2. A line matches if it matches any include row. A code restricted to Clothing or the mug covers both.
  3. An exclusion beats an inclusion, always. "Everything in Clothing, not the socks" is two rows.
  4. Only exclusions is the whole catalog minus what they name. With no include rows, everything else is in.

A restricted coupon needs something left after the narrowing, or it is refused with "This coupon does not apply to anything in your cart."

Type What a restriction means
fixed_product Only the restricted lines are discounted, each capped at its own total.
percent The percentage is worked out over the restricted lines only.
fixed_cart The cart has to contain something the restrictions name, and the amount is then capped at what those lines are worth rather than at the whole cart.

Restrict a coupon

  1. In the admin sidebar, click KahunaCart, then Coupons, and open or create a coupon.
  2. Under Restrictions, click Add restriction.
  3. Choose the Kind, then choose the product, variant, category or tag it Applies to.
  4. Tick Exclude to take what the row names back out of the coupon.
  5. Repeat for each restriction, then click Save coupon.

The coupon now covers only what the restrictions describe.

Note

Saving replaces the whole list. Removing every row makes the code cover the whole catalog again.

The product_ids column

Before migration 0027_coupon_scopes, a coupon's scope was a single product_ids JSON column. The migration copied every id in it into an include row of kind product.

The column is still written by the API, and it is still read as a fallback for a coupon that has no restriction rows at all. Saving a coupon in the admin writes restriction rows and never touches the column.

Important

product_ids is removed in the release after this one. Move any tooling that writes it over to scopes.

The other restrictions

Four options sit beside the restriction list. Each is a column on the coupon, editable in the admin and over the API.

Option Admin label What it does
free_shipping Free shipping Zeroes the postage this order was quoted.
exclude_sale_items Skip items already on sale A line a sale has already discounted is not eligible.
limit_to_items Limit to items The discount covers at most this many units. Blank for no limit.
allowed_emails Reserved for Addresses the code is reserved for. Empty for everyone.

Free shipping

The coupon writes a negative shipping adjustment equal to the postage the order was quoted. It runs after the shipping stage and before the tax stage.

Tax on the goods is untouched, because the discount comes off the postage rather than off the items. A store that taxes postage taxes the postage the customer is actually paying, which is nothing.

A cart with no postage to pay (digital goods, a pickup method, a zero flat rate) gets no row at all rather than a "Free shipping −0.00" line.

Exclude sale items

A line is skipped only when a sale has actually taken money off it, not when a sale merely covers it. A product a sale names but whose discount rounded to nothing still counts as ordinary stock.

A sale's stack_with_coupons setting controls whether that sale allows coupons. This setting controls whether the coupon allows a sale.

A bundle price counts as a sale here. 10% off a £28.50 set of tie clips is £2.85, and a code that skips items already on sale leaves the set's lines alone.

Prices the shopper named

A coupon never reaches a pay-what-you-want line. The line is left out of every coupon's base, so a percentage or fixed amount is worked out over the rest of the cart. A cart holding nothing but named prices refuses the code with "This coupon does not apply to anything in your cart".

Limit to items

limit_to_items: 2 applies the discount to at most two units, taken cheapest first. Units, not lines: a line of five mugs is five units, and a limit of two takes two of them.

Type Effect of a limit
percent The percentage is worked out over the covered units only.
fixed_cart The amount is capped at what the covered units are worth.
fixed_product Only the lines the limit reached are discounted, each capped at what its covered units are worth.

A line the limit only partly reaches is prorated and rounded down, so a discount can never exceed what the covered units are worth.

Reserve a code for named addresses

  1. In the admin sidebar, click KahunaCart, then Coupons, and open or create a coupon.
  2. Under Reserved for, enter one Email address per line: a full address such as [email protected], or a *@domain pattern such as *@partner.test.
  3. Click Save coupon.

The code now works only for the addresses you named.

Matching ignores case on both sides, and a wildcard matches a whole domain rather than a suffix of one: *@partner.test does not match [email protected].

Note

The check runs only where an address is known. The cart page has none until checkout, so a reserved code is refused at checkout rather than in the cart.

Conditions

A coupon takes the same conditions a sale and a payment method take, written against the same attributes. Use them to keep a code to a customer group, to a cart that holds a given product or variant, to a country, or to a cart of a certain size. Restrictions say which lines a code takes money off. Conditions say whether the code works at all.

  1. In the admin sidebar, click KahunaCart, then Coupons, and open or create a coupon.
  2. Under Conditions, click Add condition.
  3. Pick what to check, how to compare it, and the value.
  4. Click Save coupon.

Every row has to hold. A coupon with no rows has no conditions. Over the API, a coupon carries conditions, the same nested document a sale takes, so any and none groups work there too. See API reference.

A code that fails a condition is refused with a sentence that names it, such as "This code is only for customers in Trade." It is dropped from the cart like any refused code. The sentences are in Error messages.

A set of conditions has to make sense when you save it. The editor and the API refuse one that doesn't, with a sentence naming the problem, and write nothing. See Rules and conditions for what counts.

Reserved for a customer group

A code for one customer group is a coupon with one condition: Customer group is the group's code.

  1. Open KahunaCart → Coupons, then click New coupon.
  2. Set the code to TRADE15, the type to Percentage off, and the percent to 15.
  3. Under Conditions, click Add condition. Pick Customer group, is, and type trade.
  4. Click Create coupon.

A signed-in customer in Trade gets 15% off. A signed-in customer who isn't in Trade is told "This code is only for customers in Trade." A guest can type the code into the cart, because they may still sign in. It is judged again at checkout, and a guest is judged as a guest. Typing a Trade member's email address doesn't make them a member. See Whose data counts.

A customer in several groups matches when any of them is Trade. A guest counts as the store's default group, if there is one.

For a discount a group gets with no code to type, a price list or a sale with a customer_group condition fits better. A code is for when the group should have to know about it.

A code that needs something in the cart

A merchant asked for this one. A voucher on the wonky tie clip should work only while a full-price clip is in the same cart. That is one condition on the variant.

  1. Make the coupon. Pick Fixed amount off matching products and set the amount to 5.00.
  2. Under Restrictions, add the wonky clip, so the 5.00 comes off the wonky clip only.
  3. Under Conditions, pick Cart (by variant), contains, and search for the full-price clip.
  4. Click Save coupon.

A cart with only the wonky clip is told "This code needs Tie Clip in your cart." Once the full-price clip goes in, the code applies to the wonky clip. Take the full-price clip out again and the code is dropped with the same sentence.

Cart (by product) does the same for any variant of a product. Contains any of takes a list. Doesn't contain turns it round: a code that can't be used while a given product is in the cart.

A code for a first order

customer_order_count is how many orders a signed-in customer has already placed. It counts completed or paid orders that weren't refunded, and leaves out carts, drafts, cancelled orders and test orders. Orders this customer has placed is 0 makes a welcome code. Is at least 3 makes a thank-you for regulars.

A guest has placed none. So a first-order code doesn't stop someone who orders as a guest every time. Add allowed emails or a one-use limit where that matters.

The cart page and checkout

The cart page doesn't know everything checkout knows. A guest has no email or address until they press Continue at checkout, and they may sign in on the way. So a condition the cart can't answer yet waits for checkout, instead of refusing the code on the cart page.

The condition is about In the cart At checkout
What is in the cart, the order's currency or the other codes on it Judged Judged
The customer: group, email, email domain, order count Judged for a signed-in customer, waits for a guest Judged, a guest as a guest
The delivery country Waits Judged

At checkout every condition is judged. A fact the store still doesn't have, like a country on an order with no address, doesn't match.

Whose data counts

A coupon's customer conditions count a customer's own data only when the shopper is signed in as that customer. Checkout puts a customer on the order from the email a guest types, and an address proves nothing about who typed it. So a guest is judged as a guest, even at checkout, and even when the address matches a customer:

  • Their group is the store's default group, if there is one, and no other.
  • They have no email or email domain to match. A code for acme.com addresses is for signed-in customers. To hand a code to named people, use allowed emails.
  • They have placed no orders.

A signed-in Trade member keeps their groups. So does a customer your staff attached to a till sale or an order made in the admin, because the cashier has checked who they are. Price lists, sales, payment methods and checkout fields follow the same rule. See Whose customer data a rule reads.

Where conditions are not checked

  • A code from a link is held on the way in, without its conditions, the way it is held without its minimum total. They are judged the moment the code meets a cart.
  • Product cards draw no deduction for a code with conditions. A card can't tell who is shopping or what will be in the basket.
  • The last check at order completion stays narrow. The conditions were judged when the cart was last priced, moments before payment. See When a coupon lapses mid-checkout.

Campaigns, export and restore

A campaign keeps its conditions in its template, beside its restrictions, and every code it makes gets its own copy. Editing the campaign changes the next batch, never a code already sent.

Conditions go with a coupon in a store export, and they are deleted with the coupon. Merging an export into another store moves the product, variant, category and tag ids inside a condition to that store's own. An id the other store has no match for is dropped. A condition left with nothing is left out, and the restore report says so.

Limits and windows

Field Admin label Effect
enabled Enabled Off means "This coupon is no longer available".
starts_at Starts Before it, "This coupon is not active yet".
expires_at Expires At or after it, "This coupon has expired". The boundary is exclusive: a coupon expires at its timestamp, not after it.
min_total_minor Minimum cart total Measured against items_total_minor, the items subtotal, not the total.
max_total_minor Maximum cart total Measured against items_total_minor as well.
usage_limit Total uses Total redemptions across all customers. Blank means unlimited.
usage_limit_per_user Uses per customer Redemptions by one email address. Blank means unlimited.
individual_use Cannot combine with other coupons The code runs alone. See Stacking codes.

Usage counting

usage_count on the coupon row is a denormalized counter kept in step with a kahunacart_coupon_usages log. It is what the usage_limit check reads, and it is incremented with an atomic UPDATE.

Redemption is recorded inside the order-completion transaction, so an order that rolls back takes its usage row with it.

A full refund releases the redemption: the counter is decremented with a floor guard, the log rows are deleted, and the code becomes available again. A partial refund does not release it, because the order still stands and so does its discount. See Orders and the admin.

Note

The per-user limit needs an email address to enforce. Until checkout knows one, that check is skipped, so a coupon with a per-user limit of 1 can look valid on the cart page and be refused at checkout once the email is known.

Stacking codes

A cart may carry more than one discount code. The config key coupons.max_codes decides how many, five by default, and 1 restores the single-code cart. See Configuration.

Codes are applied in the order the customer added them, and each one measures what the ones before it left. Two 50%-off codes on a $100 line come to $50 and then $25, not $50 and $50. The order is never re-sorted, so the customer can read off the receipt which code they are getting.

A code that is refused is dropped on its own. The codes already on the cart stay exactly as they were.

Individual use, and which code wins

A coupon with individual_use = 1 cannot be combined with other codes. It cannot be added to a cart that already has a code, and no other code can be added while it is applied.

In both directions the code already applied is the one that stays. Dropping it to honour the one just typed would change a total the customer had already agreed to.

The refusal says which of the two situations it is, so the customer knows which code to remove. The exact strings are in Error messages.

Free shipping and stacking

Postage is only paid once. A cart carrying two free-shipping codes is not owed the postage twice: the first applied code that pays it is the one the shipping row is attributed to, and the rest find nothing left to zero.

Where the codes are stored

The cart carries data_json.coupon_codes, a list, and data_json.coupon_code holding the first of them. The singular key is kept in step so add-ons, exports and reports written before stacking keep working. CheckoutService::appliedCouponCodes() is the whole list, and appliedCouponCode() is still the first.

Redemption is recorded for every code on the order, not only the first.

In another currency

A store that sells in more than one currency keeps the coupon's own columns in the base currency and every other currency's amounts beside them. The coupon editor shows a box per currency under the discount amount and under the minimum and maximum cart totals, and the coupon API carries them as currency_amounts. A store with one currency sees none of this.

A code that has no amount in the order's currency does not apply there. It is never read as zero, and it never borrows the base number, because 5.00 dollars off is not 5.00 euro off and not 5 yen off. That is the rule for a currency that types its prices. A currency set to convert works the amount, the minimum and the maximum out from the base ones, to that currency's own decimal places, and an amount you typed still wins.

Coupon In a currency with no row of its own
Fixed cart or fixed product amount Refused. It has no amount to take off.
Any type with a minimum or maximum total set in the base currency Refused. A missing minimum never means "no minimum".
Percentage or free shipping, with no minimum or maximum Works in every currency, as it always did.

A percentage code with a minimum of 50.00 in the base currency needs a minimum in each currency it is used in, or it does not apply there. A code that never had a minimum or maximum needs nothing. A percentage code that was once a fixed one and still has a stale amount takes nothing from it.

What a shopper sees for a code that does not apply in their currency:

  • Typing it in is refused with "This code can't be used on an order in EUR." (PLUGIN_KAHUNACART.COUPON_ERROR_CURRENCY), naming the order's currency. The cart is left as it was.
  • A code already on the cart is dropped, with the same sentence, when the cart is in that currency. The cart's currency switch does this in one step; see For developers: the currency switch.
  • A code that arrives from a link is held as usual, and its banner says "Your code EARLY50 can't be used on an order in EUR" and prints no amount sentence.
  • Product cards show no deduction for it, because there is nothing it would take off.

The amounts are whole minor units of each currency and are typed with that currency's own decimal places: 4.50 for euro, 500 for yen. Leave a box empty to take an amount away. Take a currency out of currencies.enabled and its amounts are hidden but kept, so adding it back brings every code's amounts back.

Export, import and seeding

config:export and config:import carry a coupon's amounts in other currencies in the promotions section, so the coupons never arrive without the amounts that make them work in euro. How they travel, and what an import does about a currency the store does not sell in, is described under Moving a configuration between stores. A seed file gives a coupon a currency_amounts: block, EUR: {amount: '4.50', min_total: '20.00'}, which is left out with a warning on a store that does not sell in that currency. See the demo seed.

For developers: the currency switch

KahunaCart::couponCurrencyGate() returns a Coupon\CouponCurrencyGate. Its dropInapplicable(int $orderId, ?string $currency = null) takes the cart and the currency it is moving to (null means the one it is in), takes the codes that do not apply there off the cart and returns what it removed, in the order the cart listed them:

PHP
$removed = $kahunacart->couponCurrencyGate()->dropInapplicable($orderId, 'EUR');
// [['kind' => 'coupon', 'code' => 'FIVEOFF', 'currency' => 'EUR',
//   'reason' => 'unavailable_in_currency',
//   'message' => 'PLUGIN_KAHUNACART.COUPON_ERROR_CURRENCY|EUR']]

message is a language key with its values after a pipe, which CouponValidator::splitError() takes apart for translation. The method writes the cart's code list and nothing else: it does not reprice the cart, so call it before the recalculation. A gift card is never on the list, because a cart does not hold one. A redeemed card is paid into the customer's store credit in the card's own currency, and credit in another currency is named at checkout. See Store credit.

Apply and remove a coupon on the storefront

The coupon form posts to {route}/cart with action=coupon. A code field adds a code. A remove field naming a code takes that one off and leaves the rest standing. An empty code with no remove clears every code on the cart, which is what a theme written before stacking posts.

The codes are stored on the cart and the cart is recalculated. An invalid code is reported by the recalculation and dropped there.

The cart partial is handed coupon_codes, coupon_code and coupon_max_codes, so a theme can list the applied codes with a Remove beside each and hide the input once the cart is full. See Storefront.

Codes that apply from an ad campaign

An ad that is already running usually links with tracking on its URL:

TXT
https://store.test/?utm_source=facebook&utm_medium=paid&utm_campaign=intro-video

Adding &coupon=REELS20 to it would mean editing the ad, and on most ad platforms an edited ad goes back through review and stops running until it is approved. So a coupon can name the campaign instead.

  1. Open the coupon.
  2. Under Applies automatically, type the ad's utm_campaign into Ad campaign tag, for example intro-video.
  3. Click Save.

Every visitor who lands on any page with utm_campaign=intro-video now holds the code. Over the API, send utm_campaign instead.

The hold is the same one a ?coupon= link gives: the same checks on arrival, the same signed cookie, the same banner, the same coupons.link.days. See Configuration → Coupons. A visitor who clicks the ad on Tuesday and comes back on Friday by typing the address still has the code. The order carries it, which also tells you the sale came from the ad after the 30-minute visit attribution has lapsed.

Where it differs from a link:

  • The URL is left alone. There is no redirect, because the campaign tag is the order's attribution and stripping it would take the source away with it.
  • A code the visitor already has wins. One held from a link, or one already on their cart, stays where it is. An ad clicked later does not swap it.
  • Nothing is said when no code is live. A coupon that has expired, been switched off or run out is skipped in silence, because the ad may never have mentioned a code. When one is taken the visitor sees "Discount code applied." as with a link.
  • Case is ignored. Tags are stored lowercased and matched the same way, so Intro-Video on the ad finds intro-video on the coupon. A tag is at most 100 characters.

More than one coupon can name the same tag. The newest one that is live is used, so to change an ad's offer make the new code first and then switch the old one off.

Note

It works only for visits from the moment it is set: somebody who clicked the ad earlier and comes back without the tag has nothing to match. Ads opened in an app's own browser (Facebook and Instagram do this) keep their cookies there, so a visitor who later comes back in their usual browser starts without the hold.

Setting coupons.link.enabled to false switches this off along with links.

When a coupon lapses mid-checkout

A code that was valid when it was typed can stop being valid before the order is placed. It expires, someone else redeems the last one, or the cart moves outside the coupon's minimum or maximum or no longer meets its conditions. In every case KahunaCart drops the code and says so.

On the cart page. The code is dropped, the cart is recalculated without it, and the reason is flashed as an error.

At checkout submit. The order is not placed. Shipping and tax are redone before anything is charged, and if the coupon has stopped applying, the customer is sent back to checkout with the reason.

At order completion. One last re-check runs inside the completion transaction, covering only the two conditions a third party can change: the coupon being switched off, and it being used up by someone else. If either happened, completion throws Coupon is no longer valid, the whole transaction rolls back including stock and the order number, and the order stays a cart.

Error messages

These are the exact strings shown to customers, so you know what to expect in logs and flash messages.

Condition Message
Code not found This coupon code is not valid
Disabled This coupon is no longer available
Before starts_at This coupon is not active yet
At or after expires_at This coupon has expired
Below min_total_minor Your order does not reach this coupon's minimum total
Above max_total_minor Your order is above this coupon's maximum total
usage_limit reached This coupon has reached its usage limit
usage_limit_per_user reached You have already used this coupon
Restricted, nothing matching in cart This coupon does not apply to anything in your cart
Bad configuration This coupon is not configured correctly
Every reachable line is under a sale that refuses codes That code can't be combined with the current sale.
allowed_emails set and the checkout email is not one of them This coupon is reserved for another customer
exclude_sale_items set and every line is already discounted This coupon does not apply to items that are already on sale
The campaign this code belongs to has spent its budget This campaign has reached its limit
The code just typed is individual_use and the cart already has one That code can only be used on its own. Remove the code already applied and try it again.
The cart's applied code is individual_use The code already applied can only be used on its own, so no other code can be added beside it.
More codes than coupons.max_codes allows That is more discount codes than this cart can carry.
The code has no amount in the order's currency (see In another currency) This code can't be used on an order in EUR.
A customer_group condition the shopper isn't in This code is only for customers in Trade.
A customer_group condition that excludes a group the shopper is in This code isn't available to customers in Staff.
A Cart (by product) or Cart (by variant) condition the cart doesn't meet This code needs Tie Clip in your cart.
The same under doesn't contain, and the cart holds it This code can't be used with Wonky Tie Clip in your cart.
A delivery country condition This code is only for orders delivered to United Kingdom. / This code can't be used on orders delivered to France.
A cart total condition The minimum or maximum total sentence above
A customer email or email domain condition This coupon is reserved for another customer
A condition on the number of items, lines or products Your cart doesn't have the number of items this code needs.
A condition on the customer's past orders This code depends on how many orders you have placed with us before.
A condition on the other codes in the cart This code depends on which other codes are on your cart.
A condition on a line that no line meets Nothing in your cart meets this code's conditions.
A condition an add-on added Your order doesn't meet this code's condition on Loyalty tier.

The conditions' sentences and most of the others are translated language strings: PLUGIN_KAHUNACART.COUPON_ERROR_SALE_CONFLICT, COUPON_ERROR_EMAIL, COUPON_ERROR_SALE_ITEMS, COUPON_ERROR_INDIVIDUAL_USE, COUPON_ERROR_ALREADY_INDIVIDUAL, COUPON_ERROR_TOO_MANY_CODES, COUPON_ERROR_GROUP_LIMIT, COUPON_ERROR_CURRENCY, and COUPON_ERROR_GROUP_ONLY through COUPON_ERROR_CONDITION for the conditions. A condition refusal names a group by its label, a product by its title, and a variant as "Product — Variant". The rest are English literals.

Receipts

Discounts appear as adjustment rows with a negative amount_minor and a label of Coupon plus the uppercased code. Free shipping appears as a negative shipping row labelled from PLUGIN_KAHUNACART.COUPON_FREE_SHIPPING.

The adjustment's source records the coupon id, code and type, the basis it measured, the base the discount was computed on, the eligible line ids, how many units were covered, the item limit that produced them, and whether the coupon was restricted, skipped sale items or paid the postage.

Every row of one discount also carries group, the id its siblings share, and share_of_group_minor, this line's part of the discount. A percent or fixed_cart discount adds group_total_minor, the whole discount; fixed_product does not, because its per-line amounts were never one number to divide.

Campaigns: many codes, one offer

A promotion often needs hundreds of codes: one per conference badge, one per magazine insert, one per abandoned cart. Each is redeemable once, they all carry the same discount and restrictions, and the campaign as a whole usually has a budget.

A campaign is the header those codes hang off. The codes under it are ordinary coupon rows with a group_id, so the validator, the restriction matrix, the redemption log and the reports all treat a generated code exactly as one a merchant typed in by hand.

Field What it does
name What the merchant calls the campaign.
prefix Put in front of every code it mints, so AUTUMN- reads as a campaign rather than as noise.
pattern What a code looks like. # is a generated character and everything else is a literal, so ####-#### reads back as two groups of four. Defaults to ########.
enabled Off switches every code in the campaign off at once.
usage_limit Redemptions per generated code, stamped onto each one. Normally 1.
usage_limit_per_user Stamped onto each generated code.
total_usage_limit Redemptions across the whole campaign, which is the budget. Blank for no cap.
used_codes_limit How many of the campaign's codes may ever be redeemed, which is the budget in customers. Blank for no cap.
template The discount, restrictions and conditions every code in the campaign shares, with currency_amounts for the other currencies on a store that sells in more than one.

What a code is made of

Codes are drawn from Crockford's base32: the ten digits and twenty-two letters, the full alphabet less I, L, O and U. The first three are dropped because they look like 1, 1 and 0 in print, and U to keep accidental obscenities out of generated strings.

That leaves 32 symbols, so every # is five bits and the default eight-character pattern holds just over a trillion codes. Randomness comes from random_int(), so a customer who bought one $5-off code cannot guess the rest.

A pattern with no # in it describes exactly one code, so it is replaced with the default.

Generate a batch of codes

  1. In the admin sidebar, click KahunaCart, then Coupons, and open the Campaigns tab.
  2. Open the campaign, or click New campaign and set its name, prefix, pattern, limits and shared discount.
  3. Enter how many codes to mint, up to 50,000, and click Generate.
  4. Click Export CSV to download the codes for a mail merge or a print run.

The codes are live immediately, each stamped with the campaign's template and per-code limits, and on a store that sells in more than one currency with the template's amounts in each other currency too, so a minted code works in euro the moment it is made. A template with a fixed amount and no amount in a currency mints codes that do not apply there, which is the rule for any code.

Over the API the same thing is POST /kahunacart/coupons/generate with a group_id and a count, which answers the codes it minted. From a terminal it is bin/plugin kahunacart coupons:generate. See CLI.

The whole batch is drawn in memory, deduplicated against itself, checked against every code in the store in one query, and written in one transaction.

Important

A batch that would claim more than half the pattern's keyspace is refused before anything is drawn, and the refusal names the pattern and the number that would fit. Add another # to the pattern instead.

Editing the template afterwards changes what the next batch is stamped with and never what an existing code holds.

The campaign budget

total_usage_limit is redemptions across every code in the campaign. It is summed live from the codes rather than kept in a counter.

Reaching it refuses every code in the campaign with its own message, "this campaign has reached its limit", so a customer can tell it from a single code that was spent.

used_codes_limit is the other budget, counted in people rather than orders: how many of the campaign's codes may ever be redeemed. A campaign of one code per person, each good for five uses, with used_codes_limit at 50 is "the first 50 customers". Someone whose code has already been used keeps using it up to its own limit, and an unused code is refused with the same message once the fiftieth customer has bought. Both budgets can be set at once; a code has to pass both. Needs KahunaCart 1.2.5.

Delete a campaign

Deleting the header does not delete the codes. They are on orders that have already been placed and in customers' inboxes, so they go on working as ordinary codes with whatever limits they were stamped with. The response says how many were released.

API reference

All coupon endpoints require the kahunacart.settings permission.

TXT
GET    /kahunacart/coupons
POST   /kahunacart/coupons
POST   /kahunacart/coupons/generate
PATCH  /kahunacart/coupons/{id}
DELETE /kahunacart/coupons/{id}

GET    /kahunacart/coupon-groups
POST   /kahunacart/coupon-groups
GET    /kahunacart/coupon-groups/{id}
PATCH  /kahunacart/coupon-groups/{id}
DELETE /kahunacart/coupon-groups/{id}
GET    /kahunacart/coupon-groups/{id}/export

GET /kahunacart/coupon-groups/{id}/export answers a CSV (code, enabled, redemptions, the code's own limit and the day it was minted), paged internally, so a campaign of fifty thousand codes is not held in memory all at once.

On a store that sells in more than one currency a coupon and a campaign's template also carry currency_amounts, {"EUR": {"amount": "4.50", "min_total": "20.00", "max_total": ""}}, with each amount in that currency's own decimal places. An empty value takes that amount away and a key left out leaves it alone; a code the store does not sell in, or an amount that is not an amount in its currency, is a 422 with nothing written. A store with one currency gets no currency_amounts key and a 422 if it sends one. A campaign's template replaces what it held as a whole, like its other fields.

starts_at, expires_at, usage_limit, usage_limit_per_user and limit_to_items are nullable. Clearing the field in the admin clears the column rather than writing a zero.

utm_campaign is stored lowercased and trimmed. Empty or null clears it, and a tag over 100 characters is a 422 rather than a shortened tag that could never match its ad. See Codes that apply from an ad campaign.

Restrictions travel as a scopes array, each entry {kind, ref_id, exclude}. Sending it replaces the whole set, an empty list included. Omitting it leaves the restrictions standing, which is what an enable toggle sends.

Conditions travel as conditions, the nested document a sale takes, for example {"container": "all", "children": [{"attribute": "customer_group", "operator": "eq", "value": "trade"}]}. Groups are all, any and none, nested up to three deep. A bare list is read as an all. Omitting conditions leaves them standing. Null or an empty container removes them, and anything else replaces them. An attribute or operator the store doesn't publish, or a value that can't be compared, is a 422 naming the problem, and nothing is written. A coupon always comes back with conditions, an empty all when it has none. A campaign's template.conditions is the same document, checked when the campaign is saved.

JSON
{
  "code": "BUNDLE10",
  "type": "percent",
  "percent": "10",
  "free_shipping": true,
  "exclude_sale_items": true,
  "limit_to_items": 2,
  "allowed_emails": ["*@partner.test"],
  "scopes": [
    {"kind": "category", "ref_id": 4},
    {"kind": "product", "ref_id": 12, "exclude": true}
  ],
  "conditions": {
    "container": "all",
    "children": [
      {"attribute": "customer_group", "operator": "eq", "value": "trade"}
    ]
  }
}