DEV Community

Eli
Eli

Posted on • Originally published at clawmama.run

Our bundle math was wrong because Shopify rejected our cart transform — and then leaked a price

We found this one the way you always find the good ones: a community bug report that made us drop everything and go check our own function logs. A developer building a bundle app posted that when the same variant sits in the cart twice — once as a subscription line, once as a normal line — the normal line shows up in the Function input with the selling-plan price in cost.subtotalAmount. Their bundle math, computed from that cost, came out wrong. We run the same architecture in production. Here is the wall, the leak, 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 about this. 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 requires a development store or Shopify Plus. On non-Plus production stores, expand and merge are what you have.

So the split 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 documented execution order 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

The community field report (topic 657784; 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: 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." Variant-priced.
  • cost.amountPerQuantity — the unit cost.
  • sellingPlanAllocation — present on lines that carry a selling plan, with priceAdjustments and perDeliveryPrice (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 }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode
// 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);
}
Enter fullscreen mode Exit fullscreen mode

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. Runtime-changing config belongs in a metafield JSON blob — under 10,000 bytes, because larger values come back absent, not truncated.

One more warning from Shopify's own bundle guide: 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

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.

The numbers worth pinning above your desk

  • Selling plan present ⇒ lineExpand, linesMerge, lineUpdate rejected; 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 from sellingPlanAllocation.perDeliveryPrice, never cost.

Originally published at https://clawmama.run/blog/shopify-cart-transform-selling-plan/

Top comments (0)