Skip to main content
Gray Tsao
Back to writing
Published
min
10

Bundle Watch: when a discount quietly stops working, the merchant is the last to know

A Shopify bundle app that models bundles as discounts rather than as bundle products, and tells the merchant when another promotion eats the bundle discount — plus why a bundle torn in half is worse than one that never applied, why conflict detection depends on your api_version, and one failure that happened all on its own.

A failure with no error message

On a freshly created test shop I set up an ordinary promotion: Winter Sale, 30% off a snowboard. The shop also had a bundle rule: that snowboard plus ski wax, 15% off the pair.

Clicking "add bundle to cart" produced this:

Selling Plans Ski Wax     $24.95  → $21.21   🏷 Complete Snowboard + Ski Wax — 15% off
The Complete Snowboard    $699.95 → $489.97  🏷 Winter Sale 30%
Subtotal $511.18

The ski wax line kept the bundle discount, label and all. The snowboard line was taken over by the Winter Sale. The shopper sees the bundle's label and pays something that is not the bundle price.

Nothing anywhere reported an error. Not Shopify, not our discount function, not the merchant's admin.

I had not arranged this — that shop existed to take listing screenshots. Any merchant running one promotion and one bundle that touch the same product gets it. That is an entirely ordinary way to operate a store.

Why it happens

Shopify's rule is that on a non-Plus shop, two product discounts cannot both apply to the same item. When they collide the platform silently applies whichever saves the customer more, and tells nobody that the other one lost.

The genuinely bad part is that the collision is resolved per line item, not per cart. A two-component bundle where only one component is also covered by a larger promotion comes out half applied: one line keeps the bundle discount, the other is taken over.

My original model had three outcomes — we win, we lose, both apply. The real world has a fourth, and it is the dangerous one: partial break. It is worse than losing outright, for three reasons:

  1. The bundle looks alive — its label is still hanging on the surviving line
  2. The merchant's total discount goes up ($213.72 vs $108.73), so margin is eaten harder while the bundle achieves none of its commercial intent
  3. There is no error anywhere, so it cannot be diagnosed; technically nothing went wrong

This is not one vendor's bug

Before writing any code I read six competitors and roughly 92 reviews. Line the worst ones up and they are all the same problem:

Merchant Time in use What happened How they found out
Bolt Base 8 months Discounts repeatedly not applied Angry customer emails and phone calls
3D Fantasy 9 months Volume discount broke on an 80-variant product The abandoned checkout report
Ausker 5 months Free gift missing from orders, storefront showing full price Customer messages
Diaza Football 4 months The app was delisted Customers could not check out

The merchant is always the last to know, and always learns it from outside.

It is the category's shared failure mode. Meanwhile the market leader's most expensive tier is entirely reports about what happened — not one line of it answers whether anything is broken.

Bundle Watch sells exactly that one thing: check before saving, audit after selling. And report it even when the discount that loses is ours.

Three layers of detection

The same conflict logic, hung at three different moments:

When What it does Can it name the rival?
Before the merchant saves a rule Scans every discount in the shop through the Admin API and compares scopes ✅ Yes
At checkout The discount function reads the allocations already sitting on each line and compares amounts ❌ Only predicts the outcome
Daily / after an order Audits what has actually been sold, through the order API ✅ Yes

The first layer sees the most, but it is a prediction. The third sees facts, which makes it the only layer that can tell a merchant how much they have already lost. The middle one sees the least — and is the only one that knows while it is happening.

All three import the same conflict.js: the function, the settings UI, the audit dashboard and the backend. That is deliberate. What we warn about has to be exactly what happens at checkout, and two implementations will drift — a warning that has drifted is worse than no warning.

In that accidental failure above, the settings UI had in fact already flagged it: a red banner naming Winter Sale 30% and spelling out that whichever saves the customer more wins. Warning, ignored, consequence — all three in one shop, in one sitting.

Why not build "bundle products"

Nearly every bundle app takes the same route: create a parent product that represents the bundle, and merge the line items into it at checkout. Shopify's own Cart Transform requires it — the merge operation must be given a real parent variant.

I gave that route up once I had the limits in writing. Every one of these is a platform-level constraint, not a lazy vendor:

  • Bundles sell only through the Online Store, Shop and POS channels; a variant marked as bundle-only is omitted from channels that do not support bundles (fixed bundles reached Google/YouTube in early 2026; anything beyond that still has no first-party technical documentation)
  • "Bundles don't have their own shipping profiles" — rates come from the components
  • "Bundles don't track inventory by location, but by overall stock"
  • "Bundles aren't compatible with purchase options" — subscriptions, pre-orders, try-before-you-buy, none of them
  • "Bundles can't be used in exchanges even when exchanging for an identical bundle"

Each of those five maps onto a real one-star review in my research. Merchants were blaming vendors for the platform's floor.

Bundle Watch uses a pure discount function instead: the shopper adds the original products to the cart as ordinary line items, the function recognises the combination, and applies a discount. No bundle entity is ever created. The products are never touched, so SEO handles, images, reviews, channel sync and per-location inventory all stay exactly as they were — and turning the app off leaves nothing to undo.

I tested all four purchase options — subscription, prepaid, pre-order, try-before-you-buy — and all four worked, with the discount correctly stacking on top of the selling-plan-adjusted price. Competitors on the bundle-product model are blocked at the platform level, which makes this the widest gap between the two approaches.

What this route costs

Worth stating plainly, because these are real:

  • The cart and checkout show several line items, not one tidy "bundle" row
  • There is no dedicated bundle product page; the buying UI has to live on existing product pages via a theme app block
  • The bundle has no SKU of its own — merchants who need a bundle-level SKU for an ERP cannot have it here
  • It cannot ship "as a single product"; the packing slip lists the components

Three things that run against intuition

One: conflict detection depends on your api_version.

Deciding at runtime whether another discount already sits on a line requires CartLine.discountAllocations. That field does not exist in the 2025-10 schema; it arrived in 2026-04. Pinning an older version raises no error — it silently removes the entire detection capability, which is the only thing this product sells.

I pulled both schemas with shopify app function schema and compared them, and there is now a test asserting the toml's api_version is not below 2026-07.

Also: that field carries no title and no code, so a function cannot name the competing discount from inside. But discountedAmount is there, and that is enough to predict the outcome — which is the part that matters.

Two: combinesWith is a request, not a guarantee.

Our discount sets combinesWith.productDiscounts to true and still does not stack. The field expresses intent; the platform rule overrides it. Any logic that infers "will this conflict?" from combinesWith is wrong — it has to be decided from the shop's plan.

Three: one discount function applies once per cart.

When a cart qualifies for two of the merchant's bundles, it gets one of them. I got this wrong twice: first I treated it as a bug in selectionStrategy: MAXIMUM and switched to one operation per rule — at which point Shopify discarded the whole output and no discount applied at all; then I tried FIRST, which changed nothing.

selectionStrategy was never the variable. One function discount is one discount, and a discount applies once. Candidates are alternatives to each other, not items that add up.

Both times I changed the code before establishing that this was the platform working as designed. The lesson: when behaviour disagrees with expectation, confirm it is not intentional before calling it a bug.

A few traps that cost more than ten minutes each

They all present identically: everything looks fine.

  • A dev preview left behind by shopify app dev silently overrides the released version. Every deploy reports success, the version really is published, and the shop keeps showing the old one — with no error at any point. The fix is shopify app dev clean --store <shop>. The rule: when a successful deploy has no effect, suspect the dev preview before suspecting your own code.
  • The metafield name lives in five places, four of them TOML and GraphQL files that cannot import anything, so they are synchronised by hand. Missing one presents as: the discount is created successfully and never fires.
  • Without CORS the backend log is completely empty. An admin extension runs in a sandboxed iframe, so a POST carrying Authorization triggers a preflight; with no CORS headers the browser blocks it before it is sent, and the Worker receives nothing at all. When the log is empty, do not start by suspecting the secrets — they never even got the chance to be read.
  • Filling an unknown with a default value manufactures a failure that looks like success. It happened three times in one sitting: a plan defaulting to free flashed upgrade prompts at paying merchants; shop ?? '' billed successfully and redirected to a 404; a failed install was swallowed by a try/catch. The rule: an unknown is null, and gets handled explicitly.

That last one is the product, restated. We sell "do not fail silently", which makes our own silent failures the least excusable kind — and we shipped three of them during development, each costing a serious stretch of debugging.

The positioning was overturned by my own research

The original pitch was "flat pricing, never a cut of your revenue". Then I read the market leader's pricing page, which says the same thing, and a five-star review praising it in almost exactly those words.

The feature comparison was worse: the tier I had planned at $9 is free over there, and the tier I had planned at $39 is $9.99. As a cheaper bundle app, I had nowhere to stand.

The same research pointed at the way out: the leader's most expensive plan is all reporting, and none of it answers whether anything is broken — while every competitor's worst reviews are exactly that failure. So the positioning moved from "cheaper" to "more expensive, because it sells something else", and the target moved with it: merchants who have already been burned and have real revenue on the table.

For them, a few dollars of monthly fee is not the question. Not losing money silently is.

Where it stands, and when I stop

Feature-complete, verified end to end on a real shop, waiting to be submitted for review.

I wrote down what "it did not work" means before starting, because without a definition in advance, "kill it if it does not work" never becomes a decision — it just becomes indefinite sunk cost:

  • Judged 3 months after listing. No extensions.
  • Five metrics: installs, the share of shops where a scan finds at least one real conflict, free-to-paid conversion, MRR, 30-day retention
  • Any two of them in the "kill" column and it is killed. No reinterpreting, no reasons.
  • Total spend capped at $300
  • If it is killed, existing users get an email and at least 30 days first — one competitor delisted with no warning and wrecked the merchants who were relying on it, and I would rather not become the thing I criticised

The second metric is the whole hypothesis: how common are discount conflicts, really. The free scan is itself the instrument that measures it, on a sample far larger than any interview plan would reach.

If that number comes back low, this product has no reason to exist — and that is also an answer.

04Contact
Where
New Taipei, Taiwan · UTC+8
Status
Graduating June 2026 · open to software engineering roles

Say something specific

Email is fastest. I reply within a working day, usually with more questions than answers.

Or leave a message here
Gray Tsao© 2026 · Last updated September 2026