Skip to content
HeapByte — Senior Shopify Engineering Studio

Engineering · 28 October 2026

Shopify Function product discount rules: tiered volume pricing and margin protection

An automotive outfitter selling specialized off-road equipment runs a tiered pricing promotion: buy 4 suspension spacers for 10% off, or buy 8 for 20% off. A customer builds a mixed kit, adding four front spacers and four rear spacers across two distinct variant SKUs. At checkout, the store's third-party discount app fails to recognise the shared product family, treating each line as an independent sub-threshold purchase and denying the 20% tier. Frustrated, the customer enters an affiliate coupon code sent via newsletter. The checkout app suddenly compounds the coupon on top of a single-line volume markdown, slashing unit margins by 45% and fulfilling high-tensile alloy hardware below landed cost. The merchant discovers the margin leak four weeks later during inventory reconciliation.

8 min read
Written by the HeapByte engineering team

Shopify Function product discount rules checkout extensibility

The post-Scripts dilemma: moving discounts to WebAssembly

For nearly a decade, enterprise Shopify Plus stores handled complex promotions using the Ruby Script Editor. Scripts allowed developers to inspect line items and apply arbitrary price overrides directly within the checkout execution thread.

However, Scripts suffered from severe limitations: Ruby runtime interpretation added latency to checkout, execution timeouts caused silent failures, and script logic was isolated from standard GraphQL APIs.

Under Checkout Extensibility, Shopify replaced Ruby scripts with Shopify Functions. Instead of running on an interpreted server-side script engine, Functions compile to WebAssembly and run inside an isolated, ultra-secure sandbox managed directly by Shopify's core checkout pipeline.

In our checkout engineering for Forged 4x4, we designed custom checkout logic to execute within these platform boundaries, eliminating the latency of third-party discount apps while guaranteeing deterministic price execution.

  • Strict 11-million instruction ceiling: Functions must execute in under 5 milliseconds
  • 128 KB memory budget: Prevents memory leaks and heavy runtime allocations
  • Zero network access: All calculation inputs must be provided in GraphQL RunInput
  • Deterministic pure execution: Guarantees identical outputs given identical inputs

The mechanics of purchase.product-discount.run

The purchase.product-discount.run target executes whenever a buyer loads the cart, updates line quantities, or proceeds through checkout.

First, Shopify executes your function's configured GraphQL query against the live checkout context, fetching cart lines, quantities, variant IDs, parent product GIDs, and configured discount metafields.

Next, Shopify feeds the serialised JSON payload into your compiled WebAssembly module. The function aggregates volume pools, qualifies quantity tiers, applies margin protection caps, and prepares line item targets.

Finally, the function returns a structured FunctionResult payload containing an array of ProductDiscount objects and a declared discountApplicationStrategy. Because calculation happens inside the checkout core rather than on the client's browser, there is zero layout shift, no flash of un-discounted prices, and zero exposure to client-side DOM tampering.

Variant family pooling: aggregating quantities across related SKUs

A common limitation of standard discount apps is that volume pricing is tied strictly to individual variant IDs. If a merchant sells lug nuts in packs of 4 with options for Thread Pitch (M12 vs M14) and Finish (Black vs Chrome), a buyer purchasing one pack of each option is penalised with list pricing, even though they purchased 8 total units from the same product family.

To provide a modern B2B or consumer experience, volume pricing rules must pool line item quantities based on shared attributes: parent product GID, collection membership, or shared custom metafields.

When pooling across lines, the function evaluates the sum of all qualifying line quantities against the discount tier threshold, and then distributes the resulting unit markdown proportionally across each individual variant line item.

ts
import assert from "node:assert/strict";

export type DiscountType = "PERCENTAGE" | "FIXED_AMOUNT_PER_UNIT";
export type GroupingStrategy = "PARENT_PRODUCT" | "COLLECTION" | "LINE_ITEM";

export interface VolumeDiscountTier {
  minQuantity: number;
  discountType: DiscountType;
  discountValue: number;
}

export interface VolumeDiscountRule {
  ruleId: string;
  title: string;
  groupingStrategy: GroupingStrategy;
  tiers: VolumeDiscountTier[];
  maxDiscountCapPerUnitCents?: number;
  minMarginFloorCents?: number;
  allowStackingWithDiscountCodes: boolean;
  qualifyingProductParentIds?: string[];
}

export interface CartLineItem {
  id: string;
  merchandiseId: string;
  productParentId: string;
  quantity: number;
  costPerUnitCents: number;
  appliedDiscountCodes?: string[];
  isB2BPriceList?: boolean;
}

export interface ProductDiscountRunInput {
  cartLines: CartLineItem[];
  discountRules: VolumeDiscountRule[];
  cartDiscountCodes?: string[];
}

export interface ProductDiscountCandidate {
  message: string;
  targets: Array<{ cartLine: { id: string; quantity?: number } }>;
  value: {
    fixedAmount?: { amount: string; currencyCode: string };
    percentage?: { value: number };
  };
}

export interface ProductDiscountFunctionResult {
  discountApplicationStrategy: "FIRST" | "MAXIMUM";
  discounts: ProductDiscountCandidate[];
  debugSummary: {
    evaluatedLinesCount: number;
    groupedPoolsCount: number;
    discountsAppliedCount: number;
    totalSavingsCents: number;
  };
}

export function calculateTieredVolumeDiscount(
  input: ProductDiscountRunInput,
  currencyCode: string = "USD"
): ProductDiscountFunctionResult {
  const { cartLines, discountRules, cartDiscountCodes = [] } = input;
  const discounts: ProductDiscountCandidate[] = [];

  let totalSavingsCents = 0;
  let evaluatedLinesCount = 0;
  let discountsAppliedCount = 0;

  for (const rule of discountRules) {
    if (!rule.allowStackingWithDiscountCodes && cartDiscountCodes.length > 0) {
      continue;
    }

    const qualifyingLines = cartLines.filter((line) => {
      if (line.isB2BPriceList) return false;
      if (
        rule.qualifyingProductParentIds &&
        rule.qualifyingProductParentIds.length > 0 &&
        !rule.qualifyingProductParentIds.includes(line.productParentId)
      ) {
        return false;
      }
      return true;
    });

    evaluatedLinesCount += qualifyingLines.length;

    const pools: Map<string, CartLineItem[]> = new Map();
    for (const line of qualifyingLines) {
      let poolKey = line.id;
      if (rule.groupingStrategy === "PARENT_PRODUCT") {
        poolKey = line.productParentId;
      } else if (rule.groupingStrategy === "COLLECTION") {
        poolKey = "COLLECTION_POOL";
      }

      const existing = pools.get(poolKey) ?? [];
      existing.push(line);
      pools.set(poolKey, existing);
    }

    for (const [poolKey, lines] of pools.entries()) {
      const totalPoolQuantity = lines.reduce((sum, line) => sum + line.quantity, 0);
      const sortedTiers = [...rule.tiers].sort((a, b) => b.minQuantity - a.minQuantity);
      const eligibleTier = sortedTiers.find((tier) => totalPoolQuantity >= tier.minQuantity);

      if (!eligibleTier) {
        continue;
      }

      for (const line of lines) {
        let discountPerUnitCents = 0;

        if (eligibleTier.discountType === "PERCENTAGE") {
          discountPerUnitCents = Math.round(line.costPerUnitCents * (eligibleTier.discountValue / 100));
        } else {
          discountPerUnitCents = eligibleTier.discountValue;
        }

        if (rule.minMarginFloorCents !== undefined) {
          const maxAllowedDiscount = Math.max(0, line.costPerUnitCents - rule.minMarginFloorCents);
          if (discountPerUnitCents > maxAllowedDiscount) {
            discountPerUnitCents = maxAllowedDiscount;
          }
        }

        if (rule.maxDiscountCapPerUnitCents !== undefined) {
          if (discountPerUnitCents > rule.maxDiscountCapPerUnitCents) {
            discountPerUnitCents = rule.maxDiscountCapPerUnitCents;
          }
        }

        if (discountPerUnitCents <= 0) {
          continue;
        }

        const lineTotalSavingsCents = discountPerUnitCents * line.quantity;
        totalSavingsCents += lineTotalSavingsCents;
        discountsAppliedCount++;

        const decimalAmount = (discountPerUnitCents / 100).toFixed(2);

        discounts.push({
          message: `${rule.title} (Qty ${totalPoolQuantity}+)`,
          targets: [
            {
              cartLine: {
                id: line.id,
                quantity: line.quantity,
              },
            },
          ],
          value: {
            fixedAmount: {
              amount: decimalAmount,
              currencyCode,
            },
          },
        });
      }
    }
  }

  return {
    discountApplicationStrategy: "FIRST",
    discounts,
    debugSummary: {
      evaluatedLinesCount,
      groupedPoolsCount: discountRules.length,
      discountsAppliedCount,
      totalSavingsCents,
    },
  };
}
Why this function enforces integer-cent calculations and emits fixedAmount discounts per line target: in early Function implementations, teams often return percentage discount values directly to Shopify. When multiple quantity tiers apply across heterogeneous lines, Shopify's internal floating-point engine can introduce single-cent discrepancies against ERP invoice totals. By calculating unit reductions in integer cents and passing explicit decimal string values ('5.00') targeted directly to cart line IDs, we achieve exact penny parity across storefront checkout and warehouse dispatch orders.

Stacking defense: the FIRST versus MAXIMUM strategy trap

When defining a Shopify Function discount result, developers must declare a discountApplicationStrategy: FIRST or MAXIMUM.

A common architectural trap occurs when merchants configure multiple overlapping discounts and rely on MAXIMUM. If a customer qualifies for both a 15% brand-wide promotional coupon and a 20% volume tier, MAXIMUM quietly discards the volume tier rules if the promotional coupon saves marginally more across other items.

Even more dangerous is uncontrolled discount compounding. If your Function does not explicitly inspect cartDiscountCodes in the RunInput, a customer entering a 20% newsletter voucher can stack that voucher on top of a 25% tiered volume discount, producing catastrophic negative-margin sales.

Our production engine inspects the incoming cart context: if non-combinable promotional coupon codes are present, the function automatically yields priority to the voucher or suppresses the volume tier, preserving the merchant's gross margin.

Margin preservation floors: guarding gross profitability

In specialized engineering, automotive, or industrial catalogs, margins fluctuate wildly between product lines: proprietary in-house hardware might carry a 60% gross margin, while third-party electronic winches carry a lean 18% margin.

Applying blanket volume discounts across an entire catalog guarantees that sourced hardware sells at a net cash loss.

To protect merchant solvency, custom Shopify Functions enforce margin preservation floors: absolute unit markdown caps (maxDiscountCapPerUnitCents), minimum net price floors (minMarginFloorCents), and wholesale B2B price list isolation.

When this needs an engineer

You do not need a custom Shopify Function if your promotional requirements can be handled by Shopify's native automatic discounts, standard collection discounts, or simple percentage sales that apply uniformly to single SKUs.

You need an engineer when your promotional logic outgrows platform defaults: when volume tiers must aggregate across complex variant families or parent product groupings; when custom discount algorithms must migrate from sunset Ruby Scripts without adding third-party app latency; when volume pricing must coordinate defensively with promotional coupons and B2B catalog price lists; and when strict margin protection floors must guard against unprofitable checkout compounding.

Explore our engineering work in Shopify Plus development, inspect our custom applications case study collection, review our work on Checkout Extensibility bundle expansion, or examine how custom checkout calculations were delivered in the Forged 4x4 case study.

Send us your discounting matrix, SKU grouping requirements, and margin constraints. We will architect a high-performance WebAssembly discount function that keeps your checkout fast, accurate, and profitable.

Insights

Apply this to your store.

An audit turns the general principle into a specific list of changes, ordered by what actually pays back.