↑↓ move↵ open⌘K toggle

    ← All notes

    Quantity breaks with a Shopify Discount Function

    How I price 2-pack and 3-pack breaks with a Discount Function: tiers in a metafield, the highest tier wins, and checkout does the math.

    November 5, 2026 · 5 min read

    • Shopify Functions
    • Discounts
    • Checkout
    • Testing

    Quantity breaks look like a front-end feature: “2 packs save 10%, 3 packs save 20%”, the shopper picks one, the button updates. The picker is the easy part. The hard part is making sure the price the shopper saw is the price checkout charges, on every path into the cart: the PDP, the stepper in the cart drawer, a reorder link, an accelerated checkout button.

    I use a Discount Function. A working copy is in the lab, with the extension’s three files next to a picker that prints what the Function returns for the selected pack.

    Why not the theme tricks

    Before Functions I inherited three workarounds:

    • A theme script that applies a discount code past a quantity threshold. The code works for anyone who learns it, and nothing removes it when the shopper drops back to one unit in the drawer.
    • Hidden “2-pack” and “3-pack” variants priced lower, until inventory, reviews and reports are split across three variants of one product.
    • Draft orders with custom prices behind an app proxy. You need a backend, the endpoint must not trust the quantity or price the browser sends, and the shopper leaves the normal cart.

    All three put pricing logic where the browser can influence it. A Function runs inside Shopify, on the cart Shopify actually has, every time it changes. It’s also pure: no network, no clock, no randomness. Boring to reason about, which is what you want from pricing.

    The target and the input

    Line and order discounts run on cart.lines.discounts.generate.run (shipping has its own target). The input query asks for what the logic needs and nothing else:

    query CartInput {
      cart {
        lines {
          id
          quantity
          merchandise {
            __typename
            ... on ProductVariant {
              product {
                breaks: hasAnyTag(tags: ["qty-breaks"])
              }
            }
          }
        }
      }
      discount {
        discountClasses
        metafield(namespace: "$app", key: "tiers") {
          jsonValue
        }
      }
    }
    • A Function can’t read a product’s tag list, so hasAnyTag returns a boolean, aliased to breaks. Merchandising opts a product in by tagging it qty-breaks.
    • discountClasses says which classes the merchant enabled on this discount. Without PRODUCT there’s nothing to do, so the Function exits early.
    • The tiers come from a JSON metafield on the discount, read with jsonValue so they arrive parsed.

    Picking the highest tier

    export function cartLinesDiscountsGenerateRun(input) {
      if (!input.discount.discountClasses.includes(DiscountClass.Product)) {
        return { operations: [] };
      }
    
      const config = input.discount.metafield?.jsonValue ?? { tiers: [] };
      const tiers = [...config.tiers].sort((a, b) => b.min - a.min);
    
      const candidates = input.cart.lines.flatMap((line) => {
        const eligible = line.merchandise.__typename === 'ProductVariant' && line.merchandise.product.breaks;
        const tier = eligible && tiers.find((t) => line.quantity >= t.min);
        if (!tier) return [];
        return [{
          message: `${tier.percent}% off ${tier.min}+`,
          targets: [{ cartLine: { id: line.id } }],
          value: { percentage: { value: tier.percent } },
        }];
      });
    
      if (!candidates.length) return { operations: [] };
      return {
        operations: [
          { productDiscountsAdd: { candidates, selectionStrategy: ProductDiscountSelectionStrategy.All } },
        ],
      };
    }

    Sorting tiers by min, descending, and taking the first one the quantity reaches gives the highest tier with no special cases. Four units with tiers at 2 and 3 get the 3+ tier, not both and not whichever was listed first. It also means nobody editing the metafield has to keep the JSON in order.

    A missing metafield falls back to an empty list, so the discount fails closed. I’d rather lose a discount for an hour than apply one nobody configured.

    selectionStrategy: ALL is the line people get wrong. The Function returns one candidate per eligible line. With FIRST, only one candidate wins, so a cart with two tagged products gets the break on one of them. ALL applies every candidate, still subject to the discount’s combination settings.

    One trade-off to settle with the merchant up front: this counts quantity per cart line. One chocolate and one vanilla are two lines of one unit, so neither reaches the 2-pack tier. If mixed flavors should add up, sum quantities by product first and target every line of that product.

    Tiers in a metafield, not in the code

    { "tiers": [{ "min": 2, "percent": 10 }, { "min": 3, "percent": 20 }] } lives on the discount, under the app’s $app namespace. Moving 20% to 25% for a promo is an edit in the admin, through the app’s discount settings, not a deploy. Since $app belongs to the owning app, a theme or another app can’t rewrite the tiers by accident.

    The theme still needs the tiers to render the picker, and Liquid can’t read the discount’s metafield, so the PDP reads its own copy from a product or shop metafield. Two sources of truth, which is why the last section exists.

    Testing the tiers

    The logic is a pure function of its input, so it tests like one:

    const input = (quantity) => ({
      cart: { lines: [{ id: 'gid://shopify/CartLine/1', quantity,
        merchandise: { __typename: 'ProductVariant', product: { breaks: true } } }] },
      discount: { discountClasses: ['PRODUCT'],
        metafield: { jsonValue: { tiers: [{ min: 2, percent: 10 }, { min: 3, percent: 20 }] } } },
    });
    
    describe('quantity breaks', () => {
      it('does nothing below the first tier', () => {
        expect(cartLinesDiscountsGenerateRun(input(1)).operations).toEqual([]);
      });
      it('applies the highest tier reached', () => {
        const [op] = cartLinesDiscountsGenerateRun(input(4)).operations;
        expect(op.productDiscountsAdd.candidates[0].value.percentage.value).toBe(20);
      });
    });

    I add a vitest case for every bug I’ve shipped or caught in review: an untagged product, a discount without the PRODUCT class, a missing metafield, tiers saved out of order.

    The PDP promises, checkout enforces

    The lab’s picker computes its price in the browser with the same rule: drop the 0% tier, sort descending, take the first tier reached. But that number is a label, never an instruction. Nothing in the theme sends a price or a percentage to Shopify. The cart carries a variant and a quantity, and the Function decides the discount from what’s really there.

    So when the theme’s copy of the tiers and the metafield drift apart, and eventually they will, checkout is right and the PDP is a bug to fix, not a refund to issue. Never trust the browser’s math.

    Takeaways

    • Put the rule in a Discount Function on cart.lines.discounts.generate.run and the numbers in an $app JSON metafield, so tier changes don’t need a deploy.
    • Sort tiers descending, take the first match, and use selectionStrategy: ALL so every eligible line gets its own break.
    • Let the PDP show the price but never send it. Test the Function with vitest and treat checkout as the source of truth.

    More notes

    sections/contact.liquid

    Let's buildthe next one.

    Hiring for a senior Shopify role, need a storefront rebuilt, an app built or a workflow automated? Send a line.