Product discount stacking does not apply

# `productDiscountsWithTagsOnSameCartLine` does not apply when one of the two product discounts is `DiscountCodeApp` (Plus, API 2026-04)

## Summary

On a Shopify Plus shop running Admin GraphQL `2026-04`, two product-class discounts that satisfy the two-way tag match described in [`ProductDiscountsWithTagsOnSameCartLineInput`]( ProductDiscountsWithTagsOnSameCartLineInput - GraphQL Admin ) **do not stack on the same cart line** when either side is a `DiscountCodeApp` (function-backed code discount).

The exact same tag-stacking configuration works correctly when both sides are `DiscountAutomaticBasic` (native discounts).

## Environment

- Plan: **Shopify Plus** (verified via `shop.plan.shopifyPlus = true`)

- API version: `2026-04`

- Store: development store, fully installed app

- Discount function handle: `` (single handle backing both `DiscountCodeApp` test discounts)

- Discount function targets: `cart.lines.discounts.generate.run` + `cart.delivery_options.discounts.generate.run`

- Functions emit `productDiscountsAdd` with `selectionStrategy: MAXIMUM` per cart line

## Test matrix

| # | Pair | Both `productDiscounts: true` | Reciprocal tag match | Result on the cart line |

|—|------|-------------------------------|----------------------|-------------------------|

| 1 | `DiscountAutomaticBasic` ↔ `DiscountAutomaticBasic` | ✓ | ✓ | **Both apply (stacks correctly)** |

| 2 | `DiscountCodeApp` ↔ `DiscountAutomaticBasic` | ✓ | ✓ | Only the App applies; Basic is removed |

| 3 | `DiscountCodeApp` ↔ `DiscountCodeApp` | ✓ | ✓ | Only the higher-value App applies |

Tests 2 and 3 contradict the documented behavior. Test 1 confirms the feature works for the native<->native case.

—

## Test 1: Basic ↔ Basic (works as documented)

Two `DiscountAutomaticBasic` discounts, both PRODUCT-class (`customerGets.items.products.productsToAdd: []`), targeting the same product, with reciprocal `combinesWith.productDiscountsWithTagsOnSameCartLine` and matching `tags`:

**Discount Pair-A** (`gid://shopify/DiscountAutomaticNode/`):

```json

{

“__typename”: “DiscountAutomaticBasic”,

“title”: “Isolation Basic-Pair-A 7%”,

“status”: “ACTIVE”,

“tags”: [“pair-a”],

“combinesWith”: {

“productDiscounts”: true,

“productDiscountsWithTagsOnSameCartLine”: [“pair-b”]

}

}

```

**Discount Pair-B** (`gid://shopify/DiscountAutomaticNode/`):

```json

{

“__typename”: “DiscountAutomaticBasic”,

“title”: “Isolation Basic-Pair-B 3%”,

“status”: “ACTIVE”,

“tags”: [“pair-b”],

“combinesWith”: {

“productDiscounts”: true,

“productDiscountsWithTagsOnSameCartLine”: [“pair-a”]

}

}

```

**Cart result:**

Empty cart → add the target product → both auto-discounts fire on the same line:

```

Product (qty 1) $10.00 → $9.03

ISOLATION BASIC-PAIR-A 7% (-$0.70)

ISOLATION BASIC-PAIR-B 3% (-$0.27)

Subtotal: $9.03

TOTAL SAVINGS: $0.97

```

This is exactly the behavior the documentation describes. âś“

—

## Test 2: Code App ↔ Automatic Basic (fails)

One `DiscountAutomaticBasic` and one `DiscountCodeApp`, both PRODUCT-class, both targeting the same product, with reciprocal tag-stacking config.

**Automatic Basic** (`gid://shopify/DiscountAutomaticNode/`):

```json

{

“__typename”: “DiscountAutomaticBasic”,

“title”: “Isolation Test 5% (basic-test)”,

“status”: “ACTIVE”,

“tags”: [“basic-test”],

“combinesWith”: {

“productDiscounts”: true,

“productDiscountsWithTagsOnSameCartLine”: [“product10”]

}

}

```

**Code App** (`gid://shopify/DiscountCodeNode/`, code `PRODUCT10`):

```json

{

“__typename”: “DiscountCodeApp”,

“title”: “PRODUCT10”,

“status”: “ACTIVE”,

“tags”: [“product10”],

“combinesWith”: {

“productDiscounts”: true,

“orderDiscounts”: false,

“shippingDiscounts”: false,

“productDiscountsWithTagsOnSameCartLine”: [“product20”, “basic-test”]

}

}

```

**Two-way tag match holds:**

- `Basic.tags=[“basic-test”]` ⊆ `CodeApp.combinesWith=[“product20”,“basic-test”]` ✓

- `CodeApp.tags=[“product10”]` ⊆ `Basic.combinesWith=[“product10”]` ✓

**Cart result (step by step):**

1. Empty cart → add the target product → Basic 5% auto-discount fires correctly:

```

Product → $X discounted by 5%

```

2. Enter discount code `PRODUCT10` at checkout → PRODUCT10 (10%) applies, **Basic 5% is removed**:

```

Product → $X discounted by 10% (PRODUCT10 only)

No other discount line shown.

```

The Basic was eligible and applied on its own. The moment the code-backed product discount enters the cart, Shopify removes the Basic instead of stacking both — even though the documented two-way tag match passes for the pair. ✗

—

## Test 3: Code App ↔ Code App (fails)

Two `DiscountCodeApp` discounts, both PRODUCT-class, both PERCENTAGE, both targeting all products via `productScope: ALL_PRODUCTS`, with reciprocal tag-stacking config. Both back the same function handle.

**PRODUCT10** (`gid://shopify/DiscountCodeNode/`):

```json

{

“__typename”: “DiscountCodeApp”,

“title”: “PRODUCT10”,

“status”: “ACTIVE”,

“tags”: [“product10”],

“combinesWith”: {

“productDiscounts”: true,

“productDiscountsWithTagsOnSameCartLine”: [“product20”]

}

}

```

**PRODUCT20** (`gid://shopify/DiscountCodeNode/`):

```json

{

“__typename”: “DiscountCodeApp”,

“title”: “PRODUCT20”,

“status”: “ACTIVE”,

“tags”: [“product20”],

“combinesWith”: {

“productDiscounts”: true,

“productDiscountsWithTagsOnSameCartLine”: [“product10”]

}

}

```

**Two-way tag match holds:**

- `PRODUCT10.tags=[“product10”]` ⊆ `PRODUCT20.combinesWith=[“product10”]` ✓

- `PRODUCT20.tags=[“product20”]` ⊆ `PRODUCT10.combinesWith=[“product20”]` ✓

**Cart result:** add product, apply both codes → only the higher-value code (PRODUCT20 20%) applies. PRODUCT10 (10%) is silently dropped, even though the two-way match passes per the spec. ✗

—

## Verification we performed before posting

1. **Tags persistence on Shopify side.** Queried `discountNode.discount { … on DiscountCodeApp { tags } }` directly and verified that the `tags` field on each `DiscountCodeApp` contains the expected own-tag value persisted in Shopify’s tag index. Also confirmed via `discountNodes(query: “tag:”)` — the search returns the discount, proving the tag is searchable on the `tags` field.

2. **`combinesWith` persistence on Shopify side.** Queried each node’s `combinesWith.productDiscountsWithTagsOnSameCartLine` and verified the reciprocal arrays match what we sent.

3. **Status, scope, class.** All discounts are `ACTIVE`, PRODUCT-class, and `combinesWith.productDiscounts: true` (the documented prerequisite).

4. **No other interfering product-class nodes on the shop.** Pulled `discountNodes(first: 50)` and confirmed no other ACTIVE product-class discount carries `tags` or `productDiscountsWithTagsOnSameCartLine` values that would collide.

5. **Plus plan confirmed.** `shop.plan.shopifyPlus = true`.

6. **Cart actually contains the target product.** For Tests 1 and 2, the cart contained the product targeted by the Basic discount’s `customerGets.items.products`.

—

## Expected vs Actual

Per [`ProductDiscountsWithTagsOnSameCartLineInput`]( ProductDiscountsWithTagsOnSameCartLineInput - GraphQL Admin ):

> *Two discounts apply together only if each one allows at least one tag that the other is tagged with. Any number of product discounts can apply to the same line if this two-way match holds for every pair in the group.*

Per the [Discount Function API docs]( Discount Function API ):

> *All discount functions run concurrently, and have no knowledge of each other. The potential discount that a function outputs can be combined with the candidate from another discount, in alignment with the combination and stacking rules set on the discount node.*

The documented behavior says **the combination engine merges candidates per the discount-node-level rules**, regardless of whether a given candidate came from a Function or a native Shopify discount.

In Tests 2 and 3 above, both nodes are configured exactly per these docs (correct types, correct class, correct two-way tag match, correct Plus prerequisite), but the engine does not produce a combined result. Instead it silently picks one and drops the other.

The fact that Test 1 (native ↔ native) works correctly with the *same* `productDiscountsWithTagsOnSameCartLine` mechanism strongly suggests this is a defect specific to how the discount allocation engine treats function-backed product discount candidates when evaluating cross-node combinations.

—

## What we’re asking

1. Is `productDiscountsWithTagsOnSameCartLine` *supposed* to work when one or both sides is a `DiscountCodeApp` (function-backed product discount)? The current docs say it should — there is no carve-out for function-backed discounts on the `DiscountCombinesWith` object or the `ProductDiscountsWithTagsOnSameCartLineInput` input.

2. If yes, can someone with access to the discount allocation engine logs check what’s happening in this reproduction? IDs and tag values above are real (development store on `2026-04`).

3. If no (undocumented limitation), please confirm so we can build a workaround that doesn’t depend on this feature for our app’s code-discount stacking story.

We’re happy to provide the full Admin GraphQL output for both `discountNode(id: $id)` queries on each test discount, including `taggedNodes` proofs, on request.

—

## Reproduction script (Admin GraphQL)

The mutations below are exact — anyone reading this can paste them against their own Plus dev store to reproduce.

### Create both test Basic discounts (Test 1 — should stack)

Replace `<PRODUCT_GID>` with any product GID on your shop. Use the same product GID in both mutations so they target the same line.

```graphql

mutation CreatePairA($input: DiscountAutomaticBasicInput!) {

discountAutomaticBasicCreate(automaticBasicDiscount: $input) {

automaticDiscountNode { id }

userErrors { field message }

}

}

```

```json

// Pair-A variables

{

“input”: {

“title”: “Isolation Basic-Pair-A 7%”,

“startsAt”: “2026-05-13T00:00:00Z”,

“customerGets”: {

“value”: { “percentage”: 0.07 },

“items”: { “products”: { “productsToAdd”: [“<PRODUCT_GID>”] } }

},

“combinesWith”: {

“productDiscounts”: true,

“orderDiscounts”: false,

“shippingDiscounts”: false,

“productDiscountsWithTagsOnSameCartLine”: { “add”: [“pair-b”] }

},

“tags”: [“pair-a”]

}

}

```

```json

// Pair-B variables (same mutation, different input)

{

“input”: {

“title”: “Isolation Basic-Pair-B 3%”,

“startsAt”: “2026-05-13T00:00:00Z”,

“customerGets”: {

“value”: { “percentage”: 0.03 },

“items”: { “products”: { “productsToAdd”: [“<PRODUCT_GID>”] } }

},

“combinesWith”: {

“productDiscounts”: true,

“orderDiscounts”: false,

“shippingDiscounts”: false,

“productDiscountsWithTagsOnSameCartLine”: { “add”: [“pair-a”] }

},

“tags”: [“pair-b”]

}

}

```

-> Empty cart, add `<PRODUCT_GID>` → both 7% and 3% apply on the same line ✓

### Test 2 — App + Basic (won’t stack)

Create a Basic auto-discount with `tags: [“basic-test”]` and `combinesWith.productDiscountsWithTagsOnSameCartLine.add: [“product10”]` (same shape as above).

Create a `DiscountCodeApp` via `discountCodeAppCreate` with:

- `tags: [“product10”]`

- `combinesWith.productDiscounts: true`

- `combinesWith.productDiscountsWithTagsOnSameCartLine: { add: [“basic-test”] }`

- The function returns a `productDiscountsAdd` operation for the target product.

-> Empty cart, add the product → Basic fires. Enter the code → code fires, Basic is removed. ✗

### Test 3 — App + App (won’t stack)

Create two `DiscountCodeApp` discounts via `discountCodeAppCreate` backed by the same function handle, with reciprocal `tags` / `productDiscountsWithTagsOnSameCartLine` and both `productDiscounts: true`.

-> Empty cart, add a product targeted by both, enter both codes → only the higher-value code applies.

Hi @mehdi7 You did the right check on the reciprocal tag matching. The next thing I would verify is the app discount’s own discountClasses value.

For app discounts, Shopify evaluates combination rules against the classes configured on the DiscountCodeApp, rather than just the Function operation returned for that cart. If the app discount was created with more than one class, such as PRODUCT and SHIPPING, then the other discount has to allow every included class before it can combine. When that happens, productDiscountsWithTagsOnSameCartLine can be configured correctly, but class-level rules can still prevent the discounts from combining. The multi-class rule is covered in the help docs for discounts created with apps, and discountClasses is exposed on DiscountCodeApp.

This query should confirm that for each failing discount node.

query CheckDiscount($id: ID!) {
  discountNode(id: $id) {
    id
    discount {
      ... on DiscountCodeApp {
        title
        discountClasses
        tags
        combinesWith {
          productDiscounts
          orderDiscounts
          shippingDiscounts
          productDiscountsWithTagsOnSameCartLine
        }
      }
      ... on DiscountAutomaticBasic {
        title
        discountClasses
        tags
        combinesWith {
          productDiscounts
          orderDiscounts
          shippingDiscounts
          productDiscountsWithTagsOnSameCartLine
        }
      }
    }
  }
}

If either app discount includes ORDER or SHIPPING, then I would test creating the DiscountCodeApp with discountClasses: [PRODUCT] while keeping the reciprocal tag setup.

If the failing app discounts already return only discountClasses: [PRODUCT], then I agree the result looks unexpected. In that case, share the exact output from the query above for the failing discount nodes, plus the x-request-id header from the response, and I can raise this with the relevant team.

You nailed it. We were creating every DiscountCodeApp with discountClasses: [PRODUCT, ORDER, SHIPPING] upfront, originally to dodge the “discountCodeAppUpdate can’t add SHIPPING after create” limitation. Worked fine for ages, but as soon as we tried tag-stacking we hit exactly what you described — Shopify checks the counterpart’s combinesWith against the full class list on the node, and merchants almost never tick all three booleans on what’s logically a product discount.

Narrowed it to a single class matching the code’s actual target (Product → [PRODUCT], Order → [ORDER], Shipping → [SHIPPING]). For target changes we now delete + recreate, tracking the previously-synced class on the row so the recreate only fires on actual divergence.

After that, Tests 2 and 3 produce both discounts on the same cart line. Same setup, same reciprocal tags — just one less class in the registration.

Thanks for the lead. The docs frame discountClasses as “what this discount can emit” so I wouldn’t have thought to look there for a stacking issue — might be worth a doc note that the field also drives the counterpart’s class-allowance check.