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
- Create an app with Shopify CLI 4.9.0 or later and add the extension: shopify app generate extension --template discount, with JavaScript.
- Replace the generated shopify.extension.toml and src/ files with the files below.
- Run the fixtures with shopify app function run --input tests/product-and-order.input.json.
- Deploy, then create each campaign with discountAutomaticAppCreate (or discountCodeAppCreate for a code) and pass its JSON in metafields.
- Give each discount only the classes it uses, for example discountClasses: [PRODUCT] for a collection sale.
Code
shopify.extension.tomlapi_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.graphqlquery 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.
Shopify documentation
- 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.
