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:
- Cart: Supported. B2B: Supported. Draft orders: Supported.
- Checkout: Partially supported — that footnote is the selling-plan rejection.
- Subscription (Recurring Orders): Not supported.
- Order Edit, Create Order API, Pre-order / Try Before You Buy: Not supported.
Two more constraints from the same page shape any bundle architecture:
- One cart transform function per app per store; but if several apps each install one, all of them run. Your operations share the cart with every other bundle or upsell app the merchant installed.
lineUpdate(overriding a line’s price, title, or image) requires a development store or Shopify Plus. On non-Plus production stores, expand and merge are what you have.
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.subtotalAmountreflects the selling-plan price, not the variant’s regular price. Bundle calculations that trustcostare 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:
cost.subtotalAmount— “the cost of items in the cart before applying any discounts.” A starting point for discount math, variant-priced.cost.amountPerQuantity— the unit cost.sellingPlanAllocation— present on lines that carry a selling plan, withpriceAdjustments(the per-adjustment breakdown) andperDeliveryPrice(the effective per-delivery price; the docs’ own example: 6 deliveries at $48.00 → $8.00 per delivery).
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:
- 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.
- Add V to the cart twice: once with the selling plan, once without.
- Attempt a
lineExpandorlinesMergefrom your Cart Transform: observe the rejection — the documented behavior — because a selling plan is present. - In your Discount function, log
cart.lines[*].cost.subtotalAmountfor 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
- Selling plan present ⇒
lineExpand,linesMerge,lineUpdaterejected; subscriptions not supported by Cart Transform at all. - Execution order: Cart Transform (1) → Discounts (2, 5). Discounts see the transformed cart.
- One cart transform per app per store; multiple apps’ transforms all run.
lineUpdate: dev stores and Plus only.- Bundle limits from the guide: 150 components, 3 options, no nested bundles; only the owning app manages a bundle’s components.
- Input query: 3,000 bytes, cost 30, lists ≤ 100 elements; config metafields over 10,000 bytes return nothing.
- Group cart lines by
(variant, sellingPlan); read subscription prices fromsellingPlanAllocation.perDeliveryPrice, nevercost.
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.