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

Page: https://lintelagency.com/resources/shopify-functions/metafield-configured-discount

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

## Facts

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

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

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

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

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

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

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

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

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

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

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

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

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

## Shopify documentation

- [Discount Function API](https://shopify.dev/docs/api/functions/latest/discount)
- [Variables in input queries](https://shopify.dev/docs/apps/build/functions/input-queries/use-variables-input-queries)

## Related

- [Turn on a discount Function as an automatic discount](https://lintelagency.com/resources/graphql/function-backed-automatic-discount)
- [Configuring Functions with metafields, so rules change without deploys](https://lintelagency.com/journal/configuring-functions-with-metafields)
- [Tiered volume discount by quantity](https://lintelagency.com/resources/shopify-functions/tiered-volume-discount)
- [Custom app or App Store app: build or install?](https://lintelagency.com/journal/custom-app-vs-app-store-app)

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