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

  1. Create an app with the Shopify CLI and add the extension: shopify app generate extension --template discount, with JavaScript.
  2. Replace the generated shopify.extension.toml and src/ files with the files below. This example only needs the cart lines target.
  3. Run the fixtures with shopify app function run --input tests/two-tiers.input.json.
  4. Deploy with shopify app deploy and create an automatic discount with discountAutomaticAppCreate, discountClasses: [PRODUCT].
  5. Optionally set your own tiers in the discount’s $app:function-configuration metafield: {"tiers": [{"minQuantity": 2, "percentage": 5}]}.

Code

shopify.extension.toml
api_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.graphql
query 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.
  • 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.

Have something to build? Let’s talk it through.

Taking a limited number of projects at a time. Every brief is read by an engineer, not a salesperson.

Gencer KarakayaShopify Engineer

An engineer reads your brief, and we reply by email.