# 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.

Page: https://lintelagency.com/resources/shopify-functions/tiered-volume-discount

## 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.

## Facts

- **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:** 2 November 2026

## 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

```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

```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

```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

```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

```json
{ "operations": [] }
```

### tests/order-class-only.input.json

```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

```json
{ "operations": [] }
```

### tests/two-tiers.input.json

```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

```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"
      }
    }
  ]
}
```

## 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

- [Discount Function API](https://shopify.dev/docs/api/functions/latest/discount)

## Related

- [Turn on a discount Function as an automatic discount](https://lintelagency.com/resources/graphql/function-backed-automatic-discount)
- [One discount Function, configured by metafield](https://lintelagency.com/resources/shopify-functions/metafield-configured-discount)
- [Shopify Scripts stopped running. Here is where each script’s logic goes now.](https://lintelagency.com/journal/shopify-scripts-to-functions)
- [Configuring Functions with metafields, so rules change without deploys](https://lintelagency.com/journal/configuring-functions-with-metafields)

License: MIT. Free to use in your own store and in client work.
