Shopify functions

When Subscriptions Meet Bundles: the Cart Transform Selling-Plan Wall

The Cart Transform API reference says it plainly: if a selling plan is present, lineExpand, linesMerge, and lineUpdate are rejected. Subscriptions are simply not supported. So bundle apps split into two flows, and that’s where a community bug report says the selling-plan price leaks into the wrong cart line. Here’s the wall, the leak, and the input handling that makes your bundle math survive either way.

TL;DR: If a selling plan is present in the cart, Shopify rejects all Cart Transform operations (lineExpand, linesMerge, lineUpdate); subscriptions are documented as Not supported. The only viable architecture for a bundle app is two flows: one-time bundles via Cart Transform, subscription bundles via a Discount function (Cart Transform runs first, Discounts after). And per a community field report, when the same variant sits in the cart as both a subscription line and a one-time line, the one-time line’s cost.subtotalAmount can arrive with the selling-plan price. Defend against it: group lines by (variant, sellingPlan) composite keys and read subscription prices from sellingPlanAllocation, never from cost.

This is part 4 of our Shopify Functions in production series. Part 1 covered discount-code conflicts, part 2 the 10,000-byte metafield trap, part 3 the tag ceiling. This one is about what happens when two platform features — bundles and subscriptions — collide in one cart.

The scene

A developer posts in the Shopify Community with a carefully isolated bug report. They build a bundle app. One-time bundles run through a Cart Transform function (keyed on a line-item property like _easyBundle:OfferId). Subscription bundles run through a Discount function, because, and this is the first wall, Cart Transform cannot touch selling-plan lines at all.

Their architecture is the documented-correct one: two separate flows, because the platform forces the split. And yet: when the same variant sits in the cart twice — once as a subscription line, once as a normal one-time line — the normal line arrives in the Function input with the selling-plan price in cost.subtotalAmount. Bundle math computed from that cost comes out wrong. Two other community members look at the evidence and agree: this smells like variant-level price resolution leaking into the Function input before the Cart Transform ever sees the cart.

We run the same two-flow setup in production. This post is the map of the wall, the bug report behind it, and the defensive input handling that makes your math survive either way.

Wall #1: selling plans hard-reject Cart Transform operations

The Cart Transform API reference is unusually blunt. Under Invalid scenarios:

“Shopify rejects lineExpand, linesMerge, and lineUpdate operations if a selling plan is present.”

The rejection is not scoped to the selling-plan line: if a selling plan is present, the operations are rejected. The surface-compatibility table on the same page draws the rest of the wall:

Two more constraints from the same page shape any bundle architecture:

So the split the community poster arrived at isn’t a style choice. It’s the only architecture the platform permits:

Bundle type Function Why
One-time bundle Cart Transform (lineExpand / linesMerge) Native bundle UX; price presentation handled by the API
Subscription bundle Discount function (cart.lines.discounts.generate.run) Cart Transform rejects everything once a selling plan exists

The execution order documented on the Functions overview page makes this workable: Cart Transform runs first (step 1: cart lines); Discount functions run after (steps 2 and 5). Your discount logic sees the cart after the transform has expanded or merged it, which is exactly what you want for subscription bundles priced off component sums.

Wall #2: the price that leaks through

Here is the community field report (topic 657784; we flag this as unconfirmed platform behavior, since the docs define nothing here):

Same variant in the cart twice: line A with a selling plan, line B without. In the Function input, line B’s cost.subtotalAmount reflects the selling-plan price, not the variant’s regular price. Bundle calculations that trust cost are corrupted.

Whether this is a bug that gets fixed next quarter or a permanent edge of variant-level price resolution, the lesson generalizes beyond this one report: Function input is data crossing a trust boundary. Treat it like an API response, not like truth.

What the docs do define, on the cart-line input fields:

If a line has a selling-plan price, the documented place to read it is sellingPlanAllocation, not cost. And the documented way to know a line is a subscription line is that sellingPlanAllocation exists on it.

The defensive pattern: composite keys, not variant IDs

The root mistake in most bundle math we’ve audited (including, briefly, our own) is grouping cart lines by variant.id alone. A variant is not a purchasable identity in the cart — a variant plus a selling-plan context is. Once you internalize that, the fix is mechanical:

# run.graphql — query the fields that disambiguate lines, not just prices
query Input {
  cart {
    lines {
      id
      quantity
      sellingPlanAllocation {
        sellingPlan { id }
        perDeliveryPrice { amount currencyCode }
        priceAdjustments { price { amount } }
      }
      cost {
        amountPerQuantity { amount currencyCode }
        subtotalAmount { amount currencyCode }
      }
      merchandise {
        __typename
        ... on ProductVariant { id title }
      }
      bundleOffer: attribute(key: "_bundleOfferId") { value }
    }
  }
}
// Group by (variant, sellingPlan) — never by variant alone.
function lineKey(line) {
  const variantId =
    line.merchandise.__typename === "ProductVariant" ? line.merchandise.id : "none";
  const planId = line.sellingPlanAllocation?.sellingPlan?.id ?? "onetime";
  return `${variantId}::${planId}`;
}

function effectiveUnitPrice(line) {
  // Documented source of truth for subscription pricing:
  if (line.sellingPlanAllocation) {
    return parseFloat(line.sellingPlanAllocation.perDeliveryPrice.amount);
  }
  // One-time lines only: never mix the two pools.
  return parseFloat(line.cost.amountPerQuantity.amount);
}

Three rules fall out of this:

1. Never aggregate across the selling-plan boundary. A bundle offer’s lines must all come from the same key bucket. If your offer spans both, that’s two offers.

2. Read subscription prices from sellingPlanAllocation. If the leaked-price behavior in the field report is real, this is immune to it — you never read cost on a subscription line, and you never let a subscription line’s price contaminate a one-time line’s bucket.

3. Keep the input query small. The input query is fixed at build time: max 3,000 bytes excluding comments, max calculated query cost 30, list arguments capped at 100 elements. Every field you query “just in case” is budget you can’t spend when the offer rules grow. Runtime-changing config belongs in a metafield JSON blob — under 10,000 bytes, because larger values come back absent, not truncated (the silent failure we covered in part 2).

One more warning from Shopify’s own bundle guide that pairs with all of the above: line-item properties “can be modified by the browser, so they should not be relied upon for security or validation purposes.” Properties like _bundleOfferId are fine as routing hints; the authoritative bundle definition belongs in metafields your app owns.

Minimal reproduction

To see the wall (and, if it still reproduces, the leak) in a dev store:

  1. Create a selling plan group and attach it to variant V. Create a one-time bundle offer and a subscription bundle offer, both containing V.
  2. Add V to the cart twice: once with the selling plan, once without.
  3. Attempt a lineExpand or linesMerge from your Cart Transform: observe the rejection — the documented behavior — because a selling plan is present.
  4. In your Discount function, log cart.lines[*].cost.subtotalAmount for the one-time line of V and compare it against V’s regular price. If the field report still reproduces, the one-time line shows the selling-plan price. With composite-key grouping, your math is unaffected either way.

Step 3 is documented fact. Step 4 is the field report: log it and see.

Checklist: the numbers worth pinning above your desk

Where an agent fits

The split-flow architecture is a one-time design decision. The ongoing risk is drift: a merchant installs a second bundle app (remember, every app’s cart transform runs), a subscription app starts writing selling plans onto variants your offers use, or a platform update changes input behavior and your bundle math silently shifts. That’s a monitoring loop, and it’s the kind of thing a store operations agent can own: replay known carts through your functions on a schedule, diff the outputs, and flag the anomaly to the owner with the evidence attached — before a customer reports a wrong bundle price.


Verified against Shopify’s official documentation (Cart Transform Function API, Functions overview and execution order, the customized-bundle guide, Functions input-query limits) as of August 2026. The price-leak behavior is a community field report (Shopify Community topic 657784) and is labeled as such wherever it appears above.