# Buy X get Y discount

> A Shopify discount Function for buy X get Y offers: buy 2 of one product, get 1 of another free or discounted, cheapest first. Tested JavaScript.

Page: https://lintelagency.com/resources/shopify-functions/buy-x-get-y-discount

## In short

This discount Function runs a buy X get Y offer: for every 2 units of the products you list, one unit of the reward products is free, cheapest first. Quantities, percentage and product IDs live in a JSON metafield. It uses the cart.lines.discounts.generate.run target of the Discount Function API, version 2026-10, with the product class and quantity-limited cart line targets.

## 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.
3. Run the fixtures with shopify app function run --input tests/qualifies.input.json.
4. Deploy with shopify app deploy and create an automatic discount with discountAutomaticAppCreate, discountClasses: [PRODUCT].
5. Set the discount’s $app:function-configuration metafield with buyProductIds, getProductIds and, if needed, buyQuantity, getQuantity and percentage.

## Code

### shopify.extension.toml

```toml
api_version = "2026-10"

[[extensions]]
name = "Buy X get Y discount"
handle = "buy-x-get-y-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
      cost {
        amountPerQuantity {
          amount
        }
      }
      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. The product lists are
 * empty by default, so the discount does nothing until it's configured.
 * Keep the two lists separate: a product in both counts only as "buy".
 */
const DEFAULT_CONFIG = {
  buyProductIds: /** @type {string[]} */ ([]),
  buyQuantity: 2,
  getProductIds: /** @type {string[]} */ ([]),
  getQuantity: 1,
  // 100 makes the Y items free.
  percentage: 100,
  // Shown at checkout; null builds it from the numbers above ("Buy 2, get 1 free").
  message: /** @type {string | null} */ (null),
};

/**
 * @param {{buyQuantity: number, getQuantity: number, percentage: number}} config
 */
const defaultMessage = ({ buyQuantity, getQuantity, percentage }) =>
  `Buy ${buyQuantity}, get ${getQuantity} ${percentage >= 100 ? "free" : `${percentage}% off`}`;

/** @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 };

  /** @param {(typeof input.cart.lines)[number]} line */
  const productId = (line) => (line.merchandise.__typename === "ProductVariant" ? line.merchandise.product.id : null);
  const buyLines = input.cart.lines.filter((line) => config.buyProductIds.includes(productId(line)));
  const getLines = input.cart.lines.filter(
    (line) => !buyLines.includes(line) && config.getProductIds.includes(productId(line)),
  );

  const boughtUnits = buyLines.reduce((sum, line) => sum + line.quantity, 0);
  let freeUnits = Math.floor(boughtUnits / config.buyQuantity) * config.getQuantity;
  if (freeUnits === 0 || getLines.length === 0) return NO_DISCOUNT;

  // Discount the cheapest Y units first, as customers expect from "get one free".
  const targets = [];
  const cheapestFirst = [...getLines].sort(
    (a, b) => Number(a.cost.amountPerQuantity.amount) - Number(b.cost.amountPerQuantity.amount),
  );
  for (const line of cheapestFirst) {
    if (freeUnits === 0) break;
    const quantity = Math.min(line.quantity, freeUnits);
    targets.push({ cartLine: { id: line.id, quantity } });
    freeUnits -= quantity;
  }

  return {
    operations: [
      {
        productDiscountsAdd: {
          candidates: [{ message: config.message ?? defaultMessage(config), targets, value: { percentage: { value: String(config.percentage) } } }],
          selectionStrategy: "FIRST",
        },
      },
    ],
  };
}
```

## Test input and expected output

### tests/not-enough-bought.input.json

```json
{
  "cart": {
    "lines": [
      { "id": "gid://shopify/CartLine/1", "quantity": 1, "cost": { "amountPerQuantity": { "amount": "30.0" } }, "merchandise": { "__typename": "ProductVariant", "product": { "id": "gid://shopify/Product/1111111111" } } },
      { "id": "gid://shopify/CartLine/2", "quantity": 1, "cost": { "amountPerQuantity": { "amount": "12.0" } }, "merchandise": { "__typename": "ProductVariant", "product": { "id": "gid://shopify/Product/2222222222" } } }
    ]
  },
  "discount": {
    "discountClasses": ["PRODUCT"],
    "metafield": {
      "jsonValue": {
        "buyProductIds": ["gid://shopify/Product/1111111111"],
        "getProductIds": ["gid://shopify/Product/2222222222"]
      }
    }
  }
}
```

### tests/not-enough-bought.output.json

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

### tests/qualifies.input.json

```json
{
  "cart": {
    "lines": [
      { "id": "gid://shopify/CartLine/1", "quantity": 2, "cost": { "amountPerQuantity": { "amount": "30.0" } }, "merchandise": { "__typename": "ProductVariant", "product": { "id": "gid://shopify/Product/1111111111" } } },
      { "id": "gid://shopify/CartLine/2", "quantity": 1, "cost": { "amountPerQuantity": { "amount": "12.0" } }, "merchandise": { "__typename": "ProductVariant", "product": { "id": "gid://shopify/Product/2222222222" } } }
    ]
  },
  "discount": {
    "discountClasses": ["PRODUCT"],
    "metafield": {
      "jsonValue": {
        "buyProductIds": ["gid://shopify/Product/1111111111"],
        "getProductIds": ["gid://shopify/Product/2222222222"]
      }
    }
  }
}
```

### tests/qualifies.output.json

```json
{
  "operations": [
    {
      "productDiscountsAdd": {
        "candidates": [
          {
            "message": "Buy 2, get 1 free",
            "targets": [{ "cartLine": { "id": "gid://shopify/CartLine/2", "quantity": 1 } }],
            "value": { "percentage": { "value": "100" } }
          }
        ],
        "selectionStrategy": "FIRST"
      }
    }
  ]
}
```

### tests/two-sets-cheapest-first.input.json

```json
{
  "cart": {
    "lines": [
      { "id": "gid://shopify/CartLine/1", "quantity": 5, "cost": { "amountPerQuantity": { "amount": "30.0" } }, "merchandise": { "__typename": "ProductVariant", "product": { "id": "gid://shopify/Product/1111111111" } } },
      { "id": "gid://shopify/CartLine/2", "quantity": 3, "cost": { "amountPerQuantity": { "amount": "15.0" } }, "merchandise": { "__typename": "ProductVariant", "product": { "id": "gid://shopify/Product/2222222222" } } },
      { "id": "gid://shopify/CartLine/3", "quantity": 1, "cost": { "amountPerQuantity": { "amount": "12.0" } }, "merchandise": { "__typename": "ProductVariant", "product": { "id": "gid://shopify/Product/2222222222" } } }
    ]
  },
  "discount": {
    "discountClasses": ["PRODUCT"],
    "metafield": {
      "jsonValue": {
        "buyProductIds": ["gid://shopify/Product/1111111111"],
        "getProductIds": ["gid://shopify/Product/2222222222"]
      }
    }
  }
}
```

### tests/two-sets-cheapest-first.output.json

```json
{
  "operations": [
    {
      "productDiscountsAdd": {
        "candidates": [
          {
            "message": "Buy 2, get 1 free",
            "targets": [
              { "cartLine": { "id": "gid://shopify/CartLine/3", "quantity": 1 } },
              { "cartLine": { "id": "gid://shopify/CartLine/2", "quantity": 1 } }
            ],
            "value": { "percentage": { "value": "100" } }
          }
        ],
        "selectionStrategy": "FIRST"
      }
    }
  ]
}
```

## Limits and edge cases

- The reward item must already be in the cart; a discount Function can’t add lines. Add it with the theme or a cart transform.
- Keep the buy and get lists separate. A product in both counts only as a bought item in this code.
- The target quantity limits the discount to the free units, so extra reward items are charged normally.

## Questions

### Doesn’t Shopify have buy X get Y built in?

It does, for simple cases. A Function helps when you need rules the admin can’t express, for example cheapest-first rewards across several products or combining it with other logic in code.

### How do I make “buy 2, get the 3rd free” on the same product?

This code expects separate lists. For one product, change the count to group size (buyQuantity + getQuantity) and discount getQuantity units per full group.

### Can the reward be a percentage instead of free?

Yes. Set percentage, for example 50 for half price. 100 makes the reward free.

## 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)
- [Free gift with purchase](https://lintelagency.com/resources/shopify-functions/free-gift-with-purchase)
- [Tiered volume discount by quantity](https://lintelagency.com/resources/shopify-functions/tiered-volume-discount)
- [Shopify Scripts stopped running. Here is where each script’s logic goes now.](https://lintelagency.com/journal/shopify-scripts-to-functions)

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