Tiered volume discount by quantity
A Shopify discount Function that gives 5%, 10% or 15% off a product as the customer buys 3, 5 or 10 of it. Tiers live in a metafield. Tested JavaScript.
- Function API
- Discount
- Target
- cart.lines.discounts.generate.run
- Discount class
- PRODUCT
- Plan
- Any plan in a public app; Shopify Plus in a custom app
- App scope
- write_discounts
- API version
- 2026-10
- Last checked
In short
This discount Function gives a percentage off a product that grows with the quantity bought: 5% from 3, 10% from 5 and 15% from 10 by default, counting all variants of the product together. Tiers are stored in a JSON metafield. It uses the cart.lines.discounts.generate.run target of the Discount Function API, version 2026-10, with the product discount class.
How to use it
- Create an app with the Shopify CLI and add the extension: shopify app generate extension --template discount, with JavaScript.
- Replace the generated shopify.extension.toml and src/ files with the files below. This example only needs the cart lines target.
- Run the fixtures with shopify app function run --input tests/two-tiers.input.json.
- Deploy with shopify app deploy and create an automatic discount with discountAutomaticAppCreate, discountClasses: [PRODUCT].
- Optionally set your own tiers in the discount’s $app:function-configuration metafield: {"tiers": [{"minQuantity": 2, "percentage": 5}]}.
Code
shopify.extension.tomlapi_version = "2026-10"
[[extensions]]
name = "Tiered volume discount"
handle = "tiered-volume-discount"
type = "function"
[[extensions.targeting]]
target = "cart.lines.discounts.generate.run"
input_query = "src/cart_lines_discounts_generate_run.graphql"
export = "cart-lines-discounts-generate-run"
[extensions.build]
command = ""
path = "dist/function.wasm"
src/cart_lines_discounts_generate_run.graphqlquery CartLinesDiscountsGenerateRunInput {
cart {
lines {
id
quantity
merchandise {
__typename
... on ProductVariant {
product {
id
}
}
}
}
}
discount {
discountClasses
metafield(namespace: "$app", key: "function-configuration") {
jsonValue
}
}
}
src/cart_lines_discounts_generate_run.js// @ts-check
/**
* @typedef {import("../generated/api").CartLinesDiscountsGenerateRunInput} CartLinesDiscountsGenerateRunInput
* @typedef {import("../generated/api").CartLinesDiscountsGenerateRunResult} CartLinesDiscountsGenerateRunResult
*/
/** Settings stored as JSON in the discount's metafield. */
const DEFAULT_CONFIG = {
// Quantity of one product (all its variants together) → percentage off that product.
tiers: [
{ minQuantity: 3, percentage: 5 },
{ minQuantity: 5, percentage: 10 },
{ minQuantity: 10, percentage: 15 },
],
};
/** @type {CartLinesDiscountsGenerateRunResult} */
const NO_DISCOUNT = { operations: [] };
/**
* @param {CartLinesDiscountsGenerateRunInput} input
* @returns {CartLinesDiscountsGenerateRunResult}
*/
export function cartLinesDiscountsGenerateRun(input) {
// A Function may only return operations for the classes the discount was created with.
if (!input.discount.discountClasses.includes("PRODUCT")) return NO_DISCOUNT;
const config = { ...DEFAULT_CONFIG, ...input.discount.metafield?.jsonValue };
const tiers = [...config.tiers].sort((a, b) => b.minQuantity - a.minQuantity);
/** @type {Map<string, {quantity: number, lineIds: string[]}>} */
const products = new Map();
for (const line of input.cart.lines) {
if (line.merchandise.__typename !== "ProductVariant") continue;
const entry = products.get(line.merchandise.product.id) ?? { quantity: 0, lineIds: [] };
entry.quantity += line.quantity;
entry.lineIds.push(line.id);
products.set(line.merchandise.product.id, entry);
}
const candidates = [];
for (const { quantity, lineIds } of products.values()) {
const tier = tiers.find((t) => quantity >= t.minQuantity);
if (!tier) continue;
candidates.push({
message: `${tier.percentage}% off ${tier.minQuantity}+`,
targets: lineIds.map((id) => ({ cartLine: { id } })),
value: { percentage: { value: String(tier.percentage) } },
});
}
if (candidates.length === 0) return NO_DISCOUNT;
// ALL applies every product's candidate; FIRST would discount only one product.
return { operations: [{ productDiscountsAdd: { candidates, selectionStrategy: "ALL" } }] };
}
Test input and expected output
tests/below-first-tier.input.json{
"cart": {
"lines": [
{ "id": "gid://shopify/CartLine/1", "quantity": 2, "merchandise": { "__typename": "ProductVariant", "product": { "id": "gid://shopify/Product/1234567890" } } }
]
},
"discount": { "discountClasses": ["PRODUCT"], "metafield": null }
}
tests/below-first-tier.output.json{ "operations": [] }
tests/order-class-only.input.json{
"cart": {
"lines": [
{ "id": "gid://shopify/CartLine/1", "quantity": 12, "merchandise": { "__typename": "ProductVariant", "product": { "id": "gid://shopify/Product/1234567890" } } }
]
},
"discount": { "discountClasses": ["ORDER"], "metafield": null }
}
tests/order-class-only.output.json{ "operations": [] }
tests/two-tiers.input.json{
"cart": {
"lines": [
{ "id": "gid://shopify/CartLine/1", "quantity": 2, "merchandise": { "__typename": "ProductVariant", "product": { "id": "gid://shopify/Product/1234567890" } } },
{ "id": "gid://shopify/CartLine/2", "quantity": 4, "merchandise": { "__typename": "ProductVariant", "product": { "id": "gid://shopify/Product/1234567890" } } },
{ "id": "gid://shopify/CartLine/3", "quantity": 3, "merchandise": { "__typename": "ProductVariant", "product": { "id": "gid://shopify/Product/1234567891" } } },
{ "id": "gid://shopify/CartLine/4", "quantity": 1, "merchandise": { "__typename": "ProductVariant", "product": { "id": "gid://shopify/Product/1234567892" } } }
]
},
"discount": { "discountClasses": ["PRODUCT"], "metafield": null }
}
tests/two-tiers.output.json{
"operations": [
{
"productDiscountsAdd": {
"candidates": [
{
"message": "10% off 5+",
"targets": [
{ "cartLine": { "id": "gid://shopify/CartLine/1" } },
{ "cartLine": { "id": "gid://shopify/CartLine/2" } }
],
"value": { "percentage": { "value": "10" } }
},
{
"message": "5% off 3+",
"targets": [{ "cartLine": { "id": "gid://shopify/CartLine/3" } }],
"value": { "percentage": { "value": "5" } }
}
],
"selectionStrategy": "ALL"
}
}
]
}
Written by Lintel. MIT license: free to use in your own store and in client work.
Limits and edge cases
- The Function checks discount.discountClasses and returns nothing unless the discount has the PRODUCT class; returning another class’s operations isn’t allowed.
- selectionStrategy ALL applies every product’s tier. With FIRST, only one candidate would be used.
- Whether it stacks with other discounts follows the combination settings on the discount, not this code.
- A store can activate up to 25 discount Functions; they all run at the same time and don’t see each other.
Questions
- How do I create quantity breaks in Shopify without an app?
- Use a discount Function like this one: one discount holds all the tiers, and the tiers live in a metafield you can change without a deploy.
- Do variants of the same product count together?
- Yes. Quantities are added up by product ID, so two sizes of the same shirt count towards the same tier.
- Can tiers be per collection instead of per product?
- Yes. Group lines by a collection check (inAnyCollection) instead of product ID, and use input query variables for the collection IDs.
Shopify documentation
- Written for Function API version 2026-10 and checked against Shopify’s reference on the date shown. Shopify releases a new version every quarter.
- Custom apps that contain Functions only run on Shopify Plus stores; a public app with the same Function runs on any plan unless the page says otherwise.
- Every example ships with test fixtures, and we run them before publishing. Run them again after you change the code: shopify app function run.
- MIT license. Use the code in your own store or in client work.
Need this built, tested and maintained for your store? See Shopify Functions development.
