I am currently building an Upsell engine (My first Shopify app) that offers different types of upsells/bundles, I am 70% completed on the Shopify Admin app part, and working on the Storefront side, now while i might have some information about creating the rules, I’m pretty much stuck on how to handle Order and what specific Cart API I should be researching, the goal is to provide a complex rule where merchant can configure to encourage upsells with priorities set when conditions are met.
I want to ensure I’m covering all possible issues that Merchant might face.
The Cart API question splits three ways depending on where the upsell actually shows up, and they do not share much code.
If the offer sits on the product or cart page in the theme, that is the Ajax Cart API through a theme app extension, and your rules engine can live on your own server because you control that request.
If the offer has to become a bundle by the time it reaches checkout, that is Cart Transform. Three operations exist. lineExpand takes one cart line and blows it out into its components, linesMerge collapses several lines into a single bundle line, and lineUpdate overrides price, title and image. Worth knowing before you design around it, lineUpdate is limited to development stores and Shopify Plus, so if your pricing model depends on rewriting a line price you have just narrowed your addressable merchants down to Plus. You also get one cart transform function per app per store, and Shopify rejects all three operations on a line that has a selling plan attached, so subscription products fall straight out of your engine.
The part that will bite your rules design though is that a Function cannot call your server. Network access for Functions does exist, but it is Plus and enterprise with custom apps only, and the docs say it is not available on dev stores, so you cannot even prototype against it. Everything a Function knows arrives through a GraphQL input query you write, which means merchant configuration has to be sitting in metafields where that query can reach it.
For a rules engine with conditions and priorities that is a real constraint on shape. The whole rule set has to serialise into metafields and get evaluated inside the function, rather than your admin app deciding and the function asking. Much cheaper to design for that now at 70 percent than to bolt it on after.
Where is the offer meant to appear, cart page or checkout?
the money side hasnt come up yet and it shapes more of your engine than the cart api does. keep price out of cart transform. structure goes through cart transform (expand or merge), the money goes through a separate discount function, cartLinesDiscountsGenerateRun. that also defuses the lineUpdate limit mentioned above, because you only need lineUpdate if youre rewriting a line price yourself, and you dont have to if a discount function is doing it — discount functions run on every plan, not just plus.
one detail that cost me a day when i wired that up: linesMerge takes its price directly, percentageDecrease or fixedPricePerUnit at the top level, while lineExpand and lineUpdate wrap the same value in an adjustment object. same field names, different nesting, and the validation error you get back doesnt point at it.
on the rules themselves, dont re-run priority resolution inside the function. pick the winning offer in the theme at add time and stamp the outcome onto the line as line item properties, then the function only reads markers out of its input query. otherwise you maintain two copies of the same rule engine and one of them cant see your database.
and the bug that will find you in week one: shopify merges cart lines that have the same variant and identical properties. so adding the same offer twice gives you one line at quantity 2, which you cant tell apart from someone buying two loose items, and your whole set/priority structure quietly collapses. give every add a unique marker property (underscore prefix so it stays out of the customers view) and set 2 stays separate from set 1. same area: once lines are merged, dont mutate the merged parent through /cart/change, go back to the source lines and let the transform recompute.
disclosure, i build verve, rules driven upsells plus bundles on cart transform, so thats where the scar tissue is from.
Thanks alot Steve I took most of your feedback and I forgot to mention, this is a Upsell Cart Drawer app, it’s before the checkout. But regardless your advice on the Storefront aligns with what I had in mind, I just wanted to be sure.
The offer is meant to appear on the cart drawer, it’s an Upsell engine for the drawer, all it will do is replace the existing cart through theme extension, but mentioning the Cart API gives me a path to follow. thanks for the reply.
cart drawer before checkout changes one thing in what i wrote above: you might not need cart transform at all.
the drawer renders from the cart, so you can show a bundle as one grouped, correctly priced thing purely in your own ui, with no function involved. cart transform only decides what checkout and the order see afterwards. so the real question is whether the bundle has to exist as real lines past checkout — component inventory decrementing, a 3PL picking it, reporting on it — or whether it only ever has to be a price. if its only a price, a discount function on its own does the whole job, and you drop the transform, which takes the one-cart-transform-per-app limit and the selling plan restriction out of your architecture with it. worth deciding explicitly rather than by default, its a lot of surface to carry if you dont need it.
on the drawer side, with priorities in play, two races will find you. the shopper double clicks add and your engine evaluates twice against a cart that hasnt settled yet, so two offers land. and the offer thats already on screen stops qualifying while the shopper is looking at it, because they changed a quantity in the same drawer — if you re-evaluate on every cart change, offers vanish mid-click. decide early whether an accepted offer is sticky for the session or genuinely re-derived every time. both are defensible, quietly mixing them is where the confusing bug reports come from.
and dont hand sync the drawer contents after a mutation. re-read /cart.js and render from that, or let shopify render it with the sections param if youre keeping the themes own drawer markup. anything that patches the dom incrementally drifts the first time a discount or another app touches the cart.