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