A pet-gear brand I work with had a $29 per month bundle app that refused to combine with their sitewide promo code. The code had been redeemed 347 times. Every redemption silently failed in cart. The fix is not another app. It is a Shopify Discount Function and one combinesWith flag.
TL;DR: A Discount Function written in TypeScript replaces most “bundle discount” apps in under 50 lines of code, costs nothing per month, and stacks with sitewide promo codes once you flip combinesWith.productDiscounts to true on the parent automatic discount. Build a 3-tier kit (buy 3, 5, 7 units), wire it into the cart drawer, and verify the whole thing in 30 minutes.
Why this matters for your store
- Bundle apps charge $19 to $79 per month and almost always block stacked discounts, so your sitewide email code dies in cart.
- Shopify Scripts (Plus only) shut off June 30 2026, so any tiered-discount logic still living in the Ruby Script Editor breaks that morning.
- A Discount Function compiles to WebAssembly, runs on Shopify’s checkout server, and stacks with code discounts when you configure it correctly. One config flip saves the AOV the bundle was supposed to lift.
What is a Shopify Discount Function
A Discount Function is a piece of TypeScript or Rust code, compiled to WebAssembly, that runs inside Shopify’s Functions runtime and decides what discount to apply to a cart. Every checkout call passes the cart object into your function. The function returns a list of discounts. Shopify applies them.
Three things make Functions different from the apps and Scripts they replaced:
- They run on Shopify’s infrastructure, not yours, so there is no latency tax and no third-party JavaScript to load on the storefront.
- They stack with code and automatic discounts when
combinesWith.productDiscounts(ororderDiscounts, depending on the discount class) istrueon the parent automatic discount. - They are free to deploy on every plan from Basic through Plus. The Discount Functions API has no plan gate.
The migration deadline matters. Shopify Scripts deprecate June 30 2026 for Plus stores, and the broader non-Plus checkout extensibility deadline lands August 26 2026. Anything still in cart.scripts, the Script Editor, or the Additional Scripts box on either deadline stops firing. A Discount Function is the official replacement path.
The 3-tier kit pattern that lifts AOV
The pattern shipping this week:
| Quantity in cart | Discount applied | Use case |
|---|---|---|
| 3 of the same SKU family | 10% off | First-tier kit |
| 5 | 15% off | Mid-tier kit |
| 7 or more | 20% off | Top-tier kit |
The brand sells $24.95 leashes, harnesses, and collars. A 3-pack at 10% off lands cart subtotal around $67.36. The 5-pack tier crosses the brand’s $50 free shipping threshold and drives the loudest jump in cart-to-checkout conversion. The 7-pack tier is rare in volume but lifts AOV roughly 18% on the sessions it converts.
You can extend this to mixed-SKU bundles, but the cleanest first ship is single-SKU tiers because the cart logic stays simple. Mixed-SKU gets layered after the function works in production for two weeks.
The Discount Function code, end to end
Install the Shopify CLI and scaffold the extension:
# terminal: project root
shopify app generate extension \
--type=product_discount \
--name=tiered-kit-discount
This creates an extensions/tiered-kit-discount/ folder with run.graphql (the cart query), run.ts (the function), and shopify.extension.toml (config).
The cart query (run.graphql):
query RunInput {
cart {
lines {
id
quantity
merchandise {
... on ProductVariant {
id
product { id }
}
}
}
}
}
The function logic (run.ts, 31 lines):
// extensions/tiered-kit-discount/src/run.ts
import type { RunInput, FunctionRunResult } from "../generated/api";
const TIERS = [
{ qty: 7, percent: 20 },
{ qty: 5, percent: 15 },
{ qty: 3, percent: 10 },
];
export function run(input: RunInput): FunctionRunResult {
const totalQty = input.cart.lines.reduce(
(sum, line) => sum + line.quantity, 0
);
const tier = TIERS.find((t) => totalQty >= t.qty);
if (!tier) return { discounts: [] };
return {
discounts: [{
message: `Kit ${tier.percent}% off`,
targets: input.cart.lines.map((l) => ({
productVariant: { id: l.merchandise.id }
})),
value: { percentage: { value: tier.percent.toString() } }
}],
discountApplicationStrategy: "FIRST"
};
}
Tiers are ordered descending so the highest-qualifying tier wins automatically. The FIRST strategy means Shopify applies your function’s first matching discount and skips the rest. Keep the tier order high to low or you will silently apply 10% to a 7-unit cart.
Build and deploy:
shopify app build
shopify app deploy
The deploy uploads the WebAssembly binary to Shopify. The function is then attached to a parent automatic discount in admin (Discounts, Create automatic discount, Tiered Kit Discount). The parent discount is where you set the schedule and the combinesWith flags.
The combinesWith trap (the part nobody documents)
This is the bug that costs merchants money. By default, a new automatic discount has combinesWith.productDiscounts = false, orderDiscounts = false, and shippingDiscounts = false. Translation: your Discount Function will not stack with any code discount, including the sitewide promo your email list has been using for two years.
On the brand I shipped this for, their sitewide code had been redeemed 347 times. With the bundle app installed, every one of those redemptions silently failed in cart because the app blocked stacking. After installing the Discount Function with default combinesWith flags, the same thing happened. Code redemption rate stayed dead.
The fix is a single GraphQL mutation against the Shopify Admin Discounts API:
mutation {
discountAutomaticAppUpdate(
automaticAppDiscount: {
combinesWith: {
productDiscounts: true
orderDiscounts: true
shippingDiscounts: true
}
}
id: "gid://shopify/DiscountAutomaticApp/REPLACE_ID"
) {
userErrors { field message }
}
}
You can run this in the Shopify GraphiQL App in admin or through the Admin API. After the flip, a customer with 5 leashes in cart and the sitewide code applied gets the 15% kit tier first and the code discount second, applied on the discounted subtotal. The combinesWith reference covers the full matrix of which discount classes can stack with which.
Storefront wiring (showing the saving in cart)
The Function applies the discount at checkout, but customers want to see the saving in the cart drawer or the cart page. Two ways to surface it.
The simplest reads cart.cart_level_discount_applications in Liquid:
{%- comment -%} sections/cart-drawer.liquid {%- endcomment -%}
{%- if cart.cart_level_discount_applications.size > 0 -%}
{%- for application in cart.cart_level_discount_applications -%}
<div class="cart-discount">
{{ application.title }}: -{{ application.total_allocated_amount | money }}
</div>
{%- endfor -%}
{%- endif -%}
This works because Discount Functions render into cart.cart_level_discount_applications the same way Scripts and apps did. No JavaScript needed.
For an upsell hint above the cart total (“buy 2 more for 15% off”), calculate the gap inline:
{%- assign total_qty = cart.item_count -%}
{%- assign next_tier = 0 -%}
{%- if total_qty < 3 -%}
{%- assign next_tier = 3 -%}
{%- elsif total_qty < 5 -%}
{%- assign next_tier = 5 -%}
{%- elsif total_qty < 7 -%}
{%- assign next_tier = 7 -%}
{%- endif -%}
{%- if next_tier > 0 -%}
<p>Add {{ next_tier | minus: total_qty }} more to unlock the next kit discount</p>
{%- endif -%}
Rendered above the cart subtotal, the same hint moved my last bundle builder ship from 1.4 to 1.7 items per checkout over a 14-day window. The lift is small but real and compounds with every other CRO change in the cart.
To measure whether that hint actually moves AOV, drop an impression pixel through a script tag inside the same Custom Liquid section. It survives the theme editor reload and fires cleanly on every cart render.
How to verify it actually works
Three checks, total time under five minutes:
- Admin test. In Shopify admin, go to Discounts, your automatic discount, Test. Add 3 of the qualifying SKU. Confirm the discount line item appears and the percentage is correct.
- Storefront test. Open an incognito window, add 5 units to cart, proceed to checkout. The 15% line should appear in the order summary. Apply your sitewide code. Both should stack.
- Live order. Place a real $1 test order through Shopify’s Bogus Gateway. Confirm the order in admin shows both discount applications in the order timeline.
If any step fails, the bug is almost always one of: tier order in TIERS is wrong, combinesWith was never flipped, or the parent automatic discount is paused. The function code itself rarely breaks.
The takeaway
- Replace any “bundle discount” app with a Discount Function once you cross $19 per month in app cost. The latency drops to zero and the storefront gets one fewer third-party script to load.
- Order your tier array high to low so the largest qualifying tier wins under the
FIRSTstrategy. Reverse the order and you ship 10% on a 7-unit cart. - Flip
combinesWith.productDiscountstotrueon the parent automatic discount or your sitewide code dies silently in cart. This is the most expensive default in Shopify admin. - Surface the next-tier gap in the cart drawer with a 4-line Liquid block. Small AOV nudge, free to ship, no JavaScript.
- Verify in admin test mode, then incognito storefront, then a $1 Bogus Gateway order. Five-minute check, catches every bug worth catching.
Migrating off Shopify Scripts before the June 30 2026 cutoff? My 5 real Scripts I migrated to Functions walks the Ruby-in / TypeScript-out conversion pattern for tiered shipping, free gifts, B2B tags, BXGY, and payment hiding. For everything else breaking on August 26, the non-Plus checkout extensibility audit covers the full map. If your Discount Function also needs to honor agent-initiated codes from Shopify’s agentic storefronts, the May 30, 2026 UCP cutover changes how the Discounts API surfaces those redemptions through ChatGPT and Perplexity.
Need help shipping this on your store? Book a free 30-minute call. I will look at your current bundle setup, identify what is blocking stacking, and scope the Function rebuild end to end.