Shopify Order Function Discounts Applying to Subtotal Instead of Product Variants

Hi Shopify Community,

I’m working on implementing custom discounts using Shopify’s order functions (the older API, not planning to migrate to the new discount functions yet). We have offers based on cart totals, where discounts should apply either at the cart level (order subtotal) or at the product level (individual variants/rows) depending on the offerType.

Specifically:

  • If offerType is “cart”, discount applies to the order subtotal.
  • If offerType is “all-products”, it should apply to product variants (row-level).

From the schema.graphql, I see the targets support both orderSubtotal and productVariant, but in practice, even when I target productVariant, the discount is still getting applied to the order subtotal. It’s not behaving as expected for product-level discounts.

Here’s the code we’re using:

run.graphql

query Input {
  shop{
    rules: metafield(namespace: "oscp", key: "cartRules"){
      value
    }
  }
  cart {
   cost {
    subtotalAmount {
      amount
    }
    }
   deliverableLines {
    quantity
   }
    lines {
      quantity
      cost {
        amountPerQuantity {
          amount
          currencyCode
        }
      }
      merchandise {
        __typename
        ... on ProductVariant {
          id
        }
      }
    }
  }
}

run.ts

/// Importing necessary types from the generated API
import {
  FunctionResult,
  FixedAmount,
  DiscountApplicationStrategy,
} from "../generated/api";

/**
 * @typedef {import("../generated/api").RunInput} RunInput
 * @typedef {import("../generated/api").FunctionRunResult} FunctionRunResult
 * @typedef {import("../generated/api").Target} Target
 * @typedef {import("../generated/api").ProductVariant} ProductVariant
 */

// Constant representing an empty discount
const EMPTY_DISCOUNT: FunctionResult = {
  discountApplicationStrategy: DiscountApplicationStrategy.Maximum,
  discounts: [],
};

// Configuration type (currently empty string, might be placeholder)
type Configuration = "";

/**
 * Function to calculate discounts based on input rules and cart data.
 * @param {RunInput} input - Input data containing shop rules and cart information.
 * @returns {FunctionRunResult} - Result containing applicable discounts.
 */
export function run(input) {
  // Parsing shop rules from input JSON
  const cartRules = JSON.parse(input.shop.rules.value);
  
  // Extracting cart amount from input
  const cartAmount = parseFloat(input.cart.cost.subtotalAmount.amount);
  
  let totalQuantity = 0;
  // Calculating total quantity of items in the cart
  for (const line of input.cart.deliverableLines) {
    totalQuantity += line.quantity;
  }

  let applicableDiscount = null;

  // Iterating through each rule in shop rules
  cartRules.forEach(rule => {
    const discountStatus = rule.discountStatus;
    const discountLabel = rule.discountLabel;
    const offerType = rule.offerType;
    const offerValues = rule.offerValues || [];

    offerValues.forEach(offer => {
      const offerCondition = offer.offerCondition;
      const minCartValue = offer.minCartValue;
      const minCartQuantity = offer.minCartQuantity;
      const type = offer.type;
      const discountValue = offer.discountValue;

      let conditionMet = false;

      if (discountStatus) {
        if (
          (offerCondition === "order-amount-value" &&
            cartAmount >= parseFloat(minCartValue) &&
            parseFloat(minCartValue) !== 0) ||
          (offerCondition === "order-quantity-value" &&
            totalQuantity >= minCartQuantity &&
            minCartQuantity !== 0)
        ) {
          conditionMet = true;
        }
      }

      if (conditionMet) {
        let discountDetails;
        if (type === "percent") {
          discountDetails = { percentage: { value: parseFloat(discountValue) } };
        } else if (type === "fixed") {
          discountDetails = {
            fixedAmount: { amount: parseFloat(discountValue).toFixed(2) },
          };
        }

        let targets = [];
        if (offerType === "all-products") {
          // Apply discount to all product variants in the cart
          targets = input.cart.lines
            .filter((line: any) => line.merchandise.__typename === "ProductVariant")
            .map((line: any) => ({
              productVariant: {
                id: line.merchandise.id,
                quantity: line.quantity,
              },
            }));
        } else if (offerType === "cart") {
          // Apply discount to the whole cart subtotal
          targets = [{
            orderSubtotal: {
              excludedVariantIds: [],
            },
          }];
        }

        // Log which offer and targets are applied
        console.log("Applied Offer:", JSON.stringify({
          discountLabel,
          offerType,
          discountDetails,
          targets,
        }));

        applicableDiscount = {
          targets,
          message: discountLabel,
          value: discountDetails,
        };
      }
    });
  });

  // If applicable discount found, return it, otherwise return empty discount
  if (applicableDiscount) {
    return {
      discounts: [applicableDiscount],
      discountApplicationStrategy: DiscountApplicationStrategy.Maximum,
    };
  } else {
    return EMPTY_DISCOUNT;
  }
}

Example Input & Output

For better understanding, here’s a test example with two rules: one for “all-products” (fixed $10 off on variants if cart > $1000) and one for “cart” (12% off subtotal if quantity >=10). The cart subtotal is $1600 with 2 items.

Input (STDIN):

{
  "shop": {
    "rules": {
      "value": "[{\"discountStatus\":\"1\",\"discountLabel\":\"Test 1\",\"offerType\":\"all-products\",\"offerResources\":[],\"offerCondition\":\"order-amount-value\",\"offerValues\":[{\"offerCondition\":\"order-amount-value\",\"minCartValue\":\"1000\",\"minCartQuantity\":\"0\",\"type\":\"fixed\",\"discountValue\":\"10\"}]},{\"discountStatus\":\"1\",\"discountLabel\":\"Test 2\",\"offerType\":\"cart\",\"offerResources\":[],\"offerCondition\":\"order-quantity-value\",\"offerValues\":[{\"offerCondition\":\"order-quantity-value\",\"minCartValue\":\"0\",\"minCartQuantity\":\"10\",\"type\":\"percent\",\"discountValue\":\"12\"}]}]"
    }
  },
  "cart": {
    "cost": {
      "subtotalAmount": {
        "amount": "1600.0"
      }
    },
    "deliverableLines": [
      {
        "quantity": 2
      }
    ],
    "lines": [
      {
        "quantity": 2,
        "cost": {
          "amountPerQuantity": {
            "amount": "800.0",
            "currencyCode": "USD"
          }
        },
        "merchandise": {
          "__typename": "ProductVariant",
          "id": "gid://shopify/ProductVariant/45112436162794"
        }
      }
    ]
  }
}

Output (STDOUT):

{
  "discounts": [
    {
      "targets": [
        {
          "productVariant": {
            "id": "gid://shopify/ProductVariant/45112436162794",
            "quantity": 2
          }
        }
      ],
      "message": "Test 1",
      "value": {
        "fixedAmount": {
          "amount": "10.00"
        }
      }
    }
  ],
  "discountApplicationStrategy": "MAXIMUM"
}

Logs:

Applied Offer: {"discountLabel":"Test 1","offerType":"all-products","discountDetails":{"fixedAmount":{"amount":"10.00"}},"targets":[{"productVariant":{"id":"gid://shopify/ProductVariant/45112436162794","quantity":2}}]}

In this example, the “Test 1” rule (all-products, fixed $10) meets the condition (cart > $1000), and we’re targeting productVariant. But in the actual checkout, the discount applies to the subtotal, not the product row. The “Test 2” rule doesn’t apply here since quantity is only 2 (<10).

To clarify the rules and expected vs. actual behavior, here’s a table:

Rule Name Offer Type Condition Discount Type Value Expected Target Actual Target Applied
Test 1 all-products Cart Amount >= $1000 Fixed $10 Product Variant (row-level, e.g., $10 off per qualifying item) Order Subtotal
Test 2 cart Cart Quantity >= 10 Percent 12% Order Subtotal Order Subtotal (as expected, but not triggered in example)

Relevant schema.graphql Snippet

"""
The method for applying the discount to an order. This argument accepts either a single
`OrderSubtotalTarget` or one or more `ProductVariantTarget`s, but not both.
"""
input Target @oneOf {
  """
  A method for applying a discount to the entire order subtotal. The subtotal is the total amount of the
  order before any taxes, shipping fees, or discounts are applied. For example, if a customer places an order
  for a t-shirt and a pair of shoes, then the subtotal is the sum of the prices of those items.
  """
  orderSubtotal: OrderSubtotalTarget

  """
  A method for applying a discount to a
  [product variant](https://help.shopify.com/manual/products/variants). Product variants
  are the different versions of a product that can be purchased. For example, if a customer orders a t-shirt
  in a specific size and color, the t-shirt is a product variant. The discount can be applied to all
  product variants in the order, or to specific product variants that meet certain criteria.
  """
  productVariant: ProductVariantTarget
}

"""
The target type of a condition.
"""
enum TargetType {
  """
  The target is the subtotal of the order.
  """
  ORDER_SUBTOTAL

  """
  The target is a product variant.
  """
  PRODUCT_VARIANT
}

Has anyone encountered this issue? Is there something wrong with how I’m structuring the targets for productVariant? Or a limitation in the older functions API? Any workarounds without migrating would be appreciated!

Thanks in advance!

This is exactly what the new Discount Function API is for, to let you have a single function that can generate a mix of line-level and order-level discounts. The legacy Order Discount Function API is only able to generate order-level discounts, as indicated by its name.