One discount Function, configured by metafield

A Shopify discount Function whose product and order discounts are set per discount in a JSON metafield, so one deploy serves many campaigns.

Function API
Discount
Target
cart.lines.discounts.generate.run
Discount classes
PRODUCT, ORDER
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 reads its whole campaign from a JSON metafield on each discount: a percentage off products in chosen collections, a fixed amount off orders above a minimum, or both. One deploy serves many discounts. It uses the cart.lines.discounts.generate.run target of the Discount Function API, version 2026-10, and returns only the classes each discount has.

How to use it

  1. Create an app with Shopify CLI 4.9.0 or later 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/product-and-order.input.json.
  4. Deploy, then create each campaign with discountAutomaticAppCreate (or discountCodeAppCreate for a code) and pass its JSON in metafields.
  5. Give each discount only the classes it uses, for example discountClasses: [PRODUCT] for a collection sale.

Code

shopify.extension.toml
api_version = "2026-10"

[[extensions]]
name = "Discount configured by metafield"
handle = "metafield-configured-discount"
type = "function"

  [[extensions.targeting]]
  target = "cart.lines.discounts.generate.run"
  input_query = "src/cart_lines_discounts_generate_run.graphql"
  input_variables = { namespace = "$app", key = "function-configuration" }
  export = "cart-lines-discounts-generate-run"

  [extensions.build]
  command = ""
  path = "dist/function.wasm"
src/cart_lines_discounts_generate_run.graphql
query CartLinesDiscountsGenerateRunInput($collectionIds: [ID!]) {
  cart {
    lines {
      id
      merchandise {
        __typename
        ... on ProductVariant {
          product {
            inAnyCollection(ids: $collectionIds)
          }
        }
      }
    }
  }
  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
 */

/**
 * One Function, many campaigns: each discount created from it carries its own
 * JSON in the $app:function-configuration metafield. `collectionIds` in that
 * JSON also feeds the query variable (see input_variables in the toml).
 * Missing parts switch that part off.
 */
const DEFAULT_CONFIG = {
  // { percentage, message }; applies to products in collectionIds, or to all products when the list is empty.
  product: /** @type {{percentage: number, message?: string} | null} */ (null),
  collectionIds: /** @type {string[]} */ ([]),
  // { amount, minimumSubtotal, message }; a fixed amount off orders above minimumSubtotal (store currency).
  order: /** @type {{amount: number, minimumSubtotal?: number, message?: string} | null} */ (null),
};

/**
 * @param {CartLinesDiscountsGenerateRunInput} input
 * @returns {CartLinesDiscountsGenerateRunResult}
 */
export function cartLinesDiscountsGenerateRun(input) {
  const config = { ...DEFAULT_CONFIG, ...input.discount.metafield?.jsonValue };
  const classes = input.discount.discountClasses;
  const operations = [];

  // A Function may only return operations for the classes the discount was created with.
  if (config.product && classes.includes("PRODUCT")) {
    const targets = input.cart.lines
      .filter(
        (line) =>
          line.merchandise.__typename === "ProductVariant" &&
          (config.collectionIds.length === 0 || line.merchandise.product.inAnyCollection),
      )
      .map((line) => ({ cartLine: { id: line.id } }));
    if (targets.length > 0) {
      operations.push({
        productDiscountsAdd: {
          candidates: [
            {
              message: config.product.message ?? `${config.product.percentage}% off`,
              targets,
              value: { percentage: { value: String(config.product.percentage) } },
            },
          ],
          selectionStrategy: "FIRST",
        },
      });
    }
  }

  if (config.order && classes.includes("ORDER")) {
    // Shopify checks the minimum itself (in the store's currency), so the
    // candidate is offered and only applied when the condition holds.
    const conditions = config.order.minimumSubtotal
      ? [{ orderMinimumSubtotal: { minimumAmount: String(config.order.minimumSubtotal), excludedCartLineIds: [] } }]
      : [];
    operations.push({
      orderDiscountsAdd: {
        candidates: [
          {
            message: config.order.message ?? `${config.order.amount} off your order`,
            conditions,
            targets: [{ orderSubtotal: { excludedCartLineIds: [] } }],
            value: { fixedAmount: { amount: String(config.order.amount) } },
          },
        ],
        selectionStrategy: "FIRST",
      },
    });
  }

  return { operations };
}
Test input and expected output
tests/not-configured.input.json
{
  "cart": { "lines": [
      { "id": "gid://shopify/CartLine/1", "merchandise": { "__typename": "ProductVariant", "product": { "inAnyCollection": true } } },
      { "id": "gid://shopify/CartLine/2", "merchandise": { "__typename": "ProductVariant", "product": { "inAnyCollection": false } } }
    ] },
  "discount": { "discountClasses": ["PRODUCT", "ORDER"], "metafield": null }
}
tests/not-configured.output.json
{ "operations": [] }
tests/order-class-only.input.json
{
  "cart": { "lines": [
      { "id": "gid://shopify/CartLine/1", "merchandise": { "__typename": "ProductVariant", "product": { "inAnyCollection": true } } },
      { "id": "gid://shopify/CartLine/2", "merchandise": { "__typename": "ProductVariant", "product": { "inAnyCollection": false } } }
    ] },
  "discount": {
    "discountClasses": ["ORDER"],
    "metafield": { "jsonValue": {
        "product": { "percentage": 20, "message": "20% off summer collection" },
        "collectionIds": ["gid://shopify/Collection/1234567890"],
        "order": { "amount": 50, "minimumSubtotal": 500, "message": "50 off orders over 500" }
      } }
  }
}
tests/order-class-only.output.json
{
  "operations": [
    {
      "orderDiscountsAdd": {
        "candidates": [
          {
            "message": "50 off orders over 500",
            "conditions": [{ "orderMinimumSubtotal": { "minimumAmount": "500", "excludedCartLineIds": [] } }],
            "targets": [{ "orderSubtotal": { "excludedCartLineIds": [] } }],
            "value": { "fixedAmount": { "amount": "50" } }
          }
        ],
        "selectionStrategy": "FIRST"
      }
    }
  ]
}
tests/product-and-order.input.json
{
  "cart": { "lines": [
      { "id": "gid://shopify/CartLine/1", "merchandise": { "__typename": "ProductVariant", "product": { "inAnyCollection": true } } },
      { "id": "gid://shopify/CartLine/2", "merchandise": { "__typename": "ProductVariant", "product": { "inAnyCollection": false } } }
    ] },
  "discount": {
    "discountClasses": ["PRODUCT", "ORDER"],
    "metafield": { "jsonValue": {
        "product": { "percentage": 20, "message": "20% off summer collection" },
        "collectionIds": ["gid://shopify/Collection/1234567890"],
        "order": { "amount": 50, "minimumSubtotal": 500, "message": "50 off orders over 500" }
      } }
  }
}
tests/product-and-order.output.json
{
  "operations": [
    {
      "productDiscountsAdd": {
        "candidates": [
          {
            "message": "20% off summer collection",
            "targets": [{ "cartLine": { "id": "gid://shopify/CartLine/1" } }],
            "value": { "percentage": { "value": "20" } }
          }
        ],
        "selectionStrategy": "FIRST"
      }
    },
    {
      "orderDiscountsAdd": {
        "candidates": [
          {
            "message": "50 off orders over 500",
            "conditions": [{ "orderMinimumSubtotal": { "minimumAmount": "500", "excludedCartLineIds": [] } }],
            "targets": [{ "orderSubtotal": { "excludedCartLineIds": [] } }],
            "value": { "fixedAmount": { "amount": "50" } }
          }
        ],
        "selectionStrategy": "FIRST"
      }
    }
  ]
}

Written by Lintel. MIT license: free to use in your own store and in client work.

Limits and edge cases

  • The order minimum is passed as an orderMinimumSubtotal condition, which Shopify checks in the store’s currency.
  • With an empty collectionIds list the product percentage applies to every product.
  • Metafields over 10,000 bytes aren’t returned to Functions; keep campaigns small or split them.

Questions

Why configure a discount Function with a metafield?
Without it, every change means editing code and deploying. With a metafield, merchants or your app admin page change the numbers per discount, and the Function stays the same.
What happens if the discount lacks a class the configuration uses?
That part is skipped. The Function checks discount.discountClasses before returning product or order operations.
How do merchants change the configuration?
Set it with metafieldsSet in the GraphQL Admin API or from your app’s own settings page. Build a small form if merchants change it often.
  • 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.