跳至主要內容
Gray Tsao
回文章列表
發佈於
分鐘
9

Bundle Watch:折扣悄悄失效時,商家永遠是最後一個知道的人

一個 Shopify bundle App:把 bundle 做成折扣而不是 bundle 商品,並在折扣被別的促銷吃掉時主動告訴商家——順便講清楚為什麼「bundle 被撕成兩半」比「完全沒套用」更糟、衝突偵測的能力為什麼取決於 api_version,以及一次沒有安排、自己就發生的故障。

一次沒有錯誤訊息的故障

在一間新開的測試商店上,我建了一檔再普通不過的促銷:Winter Sale,單品雪板 30% off。商店上另外有一個 bundle 規則:雪板加雪蠟,整組 15% off。

按下前台的「加入購物車」,得到這個:

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

雪蠟那一行保留了 bundle 的折扣,連 bundle 的文案都還掛著。雪板那一行被 Winter Sale 接管。顧客看到 bundle 的標籤,付的卻不是 bundle 的價格。

沒有任何一個環節報錯。Shopify 沒有,我們的折扣函式沒有,商家後台也沒有。

這不是我安排的實驗——那間店是為了拍上架截圖才建的。只要商家同時跑一檔促銷和一個 bundle,兩者碰到同一個商品,就會這樣。那是再普通不過的營運情境。

為什麼會這樣

Shopify 的規則是:非 Plus 商店上,同一個商品不能同時套用兩個 product discount。碰到的時候,平台自動選對顧客較省的那一個,而且不告訴任何人另一個輸了

真正糟的地方在於裁決是逐行項目的,不是逐購物車。一個兩件商品的 bundle,只要其中一件同時被一檔更大的促銷蓋到,就會出來一半——一行留著 bundle 折扣,另一行被接管。

我原本的模型只有三種結果:我們贏、我們輸、兩個都套用。真實世界有第四種,而且是最危險的一種:部分斷裂。它比完全沒套用更糟,有三個理由:

  1. bundle 看起來還活著——文案還掛在存活的那一行上
  2. 商家的總折扣反而變大($213.72 vs $108.73),毛利被咬掉更多,而 bundle 的商業意圖完全沒達成
  3. 全程零錯誤訊息,所以查不出原因;技術上什麼都沒出錯

這不是某一家 App 的 bug

在動手寫程式之前,我讀了六個競品、約 92 則評論。把最嚴重的差評排在一起看,它們全部是同一類問題:

商家 用了多久 出了什麼事 怎麼發現的
Bolt Base 8 個月 折扣反覆沒套用 客戶的憤怒 email 和電話
3D Fantasy 9 個月 80 個變體的量販折扣壞掉 棄單報表
Ausker 5 個月 贈品沒加進訂單、前台顯示全價 客戶訊息
Diaza Football 4 個月 App 被下架 客戶結不了帳

商家永遠是最後一個知道的人,而且是從外面知道的。

這是整個品類的共同失敗模式。而市場龍頭最貴的那一層方案,賣的全部是「發生了什麼」的報表——沒有一項在回答「有沒有東西壞掉」。

Bundle Watch 只賣這一項:在儲存前檢查,在售出後稽核。而且我們自己輸掉時也照樣回報。

三層偵測

同一份衝突判斷邏輯,掛在三個不同的時間點上:

時機 做什麼 能說出對手的名字嗎
商家儲存規則前 用 Admin API 掃全店折扣,比對範圍是否重疊 ✅ 可以
顧客結帳當下 折扣函式讀行項目上已存在的折扣分配,比金額 ❌ 只能預測輸贏
每天/訂單成立後 用訂單 API 稽核已經賣掉的東西 ✅ 可以

第一層抓得到的東西最完整,但它是預測;第三層看得到的是已經發生的事實,所以是唯一能算出「你已經虧了多少」的一層。中間那層最弱,但它是唯一在事情正在發生的當下就知道的。

三層共用同一份 conflict.js:折扣函式、設定 UI、稽核儀表板、後端都匯入它。這是刻意的——我們警告的內容,必須就是結帳時實際發生的事。兩份實作會漂移,而漂移過的警告比沒有警告更糟

那次意外的故障裡,設定 UI 的紅色橫幅其實具名警告過 Winter Sale 30%,並寫明「whichever saves the customer more wins」。警告 → 忽略 → 後果,三個環節在同一間商店、同一個工作階段裡完整發生了一次。

為什麼不做「bundle 商品」

市面上的 bundle App 幾乎都走同一條路:建立一個代表 bundle 的父商品,結帳時把行項目合併過去。Shopify 官方的 Cart Transform 也要求這樣做——merge 操作必須提供一個真實存在的父變體。

我把那條路上的限制查清楚之後放棄了它。以下每一條都是平台層級的限制,不是哪一家廠商偷懶:

  • bundle 只能在 Online Store、Shop、POS 這幾個通路賣;標記為「只能整組賣」的變體會被不支援的通路排除(2026 年初開始開放 Google/YouTube,其餘仍未有一手技術文件)
  • 「Bundles 沒有自己的運費設定」——運費照元件各自的設定算
  • 「Bundles 不依 location 追蹤庫存,只看總量」
  • 「Bundles 不相容於 purchase options」——訂閱、預購、試用後付,全部不行
  • 「Bundles 不能用於換貨,即使換的是一模一樣的 bundle」

這五條,每一條都對應到我研究裡的一則真實差評。商家在罵廠商,但那些是平台的地板。

Bundle Watch 改走純折扣函式:顧客把原本的商品各自加進購物車,函式偵測到組合成立,套上折扣。全程沒有任何 bundle 實體被建立。 商品沒被動過,所以 SEO handle、圖片、評論、通路同步、多倉庫存全部原封不動;停用的時候也沒有東西要復原。

訂閱那一條我實測過四種 purchase option——訂閱、預付、預購、試用後付——全部通過,折扣正確地疊在 selling plan 調整後的價格上。走 bundle 商品模型的競品在平台層級就被擋死,這是差異最大的一項。

這條路的代價

得誠實列出來,因為它們是真的:

  • 購物車和結帳頁顯示成多個行項目,不是一行漂亮的「bundle」
  • 沒有獨立的 bundle 商品頁,選購 UI 得用 theme app block 掛在既有商品頁上
  • bundle 沒有自己的 SKU——需要 bundle 層級 SKU 餵 ERP 的商家,這裡做不到
  • 不能「當成單一商品出貨」,出貨單會列出各元件

三件跟直覺相反的事

一、衝突偵測的能力取決於 api_version

執行期要判斷「這一行上已經有別的折扣了嗎」,靠的是 CartLine.discountAllocations。這個欄位在 2025-10 的 schema 裡完全不存在,2026-04 才加進來。釘在舊版不會報錯,只會靜默失去整個衝突偵測能力——也就是這個產品唯一在賣的東西。

我用 shopify app function schema 把兩個版本的真實 schema 拉下來比對過,現在有一個測試斷言 toml 裡的 api_version 不得低於 2026-07。

另外,那個欄位裡沒有 title 也沒有 code——函式內部無法說出競爭折扣叫什麼名字。但 discountedAmount 有,而那足以預測輸贏,也就是真正要緊的那部分。

二、combinesWith 是「請求」,不是「保證」。

我們的折扣把 combinesWith.productDiscounts 設成 true,實測仍然不疊加。這個欄位只表達意圖,平台規則凌駕其上——任何拿 combinesWith 判斷會不會衝突的邏輯都是錯的,必須依商店方案判斷。

三、一個折扣函式,每次購物車只套用一次。

購物車同時符合商家的兩個 bundle 時,只會拿到其中一個。我在這件事上判斷錯了兩次:先當成 selectionStrategy: MAXIMUM 的 bug,改成「每條規則一個 operation」——結果 Shopify 直接丟棄整份輸出,一個折扣都沒有;再改成 FIRST,沒有任何差別。

selectionStrategy 從來不是變因。一個 function 折扣就是「一個折扣」,而一個折扣只套用一次。 candidates 是彼此的替代方案,不是可以疊加的項目。

兩次都是先動手改,才發現那是平台的正確行為。教訓:行為與預期不符時,先確認那是不是設計如此,再認定是 bug。

幾個花掉不只十分鐘的坑

它們的症狀都一樣:看起來一切正常。

  • shopify app dev 留下的 dev preview 會靜默覆蓋已發布版本。 deploy 每次都回報成功、版本也真的發布了,但商店上永遠是舊的,全程零錯誤訊息。解法是 shopify app dev clean --store <shop>。規則:deploy 成功但沒生效時,先懷疑 dev preview,不要先懷疑自己的程式碼。
  • metafield 的名稱散在五個地方,其中四個是 TOML 和 GraphQL——它們不能 import,所以只能手動同步。漏改任何一處的症狀是:折扣建立成功,但永遠不觸發。
  • 少了 CORS,後端日誌會完全空白。 admin extension 跑在沙箱 iframe 裡,帶 Authorization 的 POST 會觸發 preflight,沒有 CORS 標頭的話瀏覽器在送出前就擋掉了——Worker 收不到任何東西。日誌空白時不要先懷疑密鑰,密鑰連被讀到的機會都沒有。
  • 用預設值填補未知,會做出「看起來成功的失敗」。 同一個工作階段裡出現三次:方案預設成 free,害已付費的商家看到升級提示;shop ?? ' 讓收費成功卻回跳 404;安裝失敗被 try/catch 吞掉。規則:未知就是 null,並且明確處理。

最後那一條跟這個產品是同一件事。我們賣的就是「不要靜默失敗」,所以自己更不能有——開發過程中我們自己做出過三個靜默失敗,每一個都花掉可觀的除錯時間。

定位被自己的研究推翻過一次

原本的行銷主張是「固定價,永遠不抽你營收的成」。查到市場龍頭的定價頁時,上面就寫著同樣的話,而且有五星評論用幾乎一模一樣的句子在稱讚它。

更難堪的是功能對照:我原本規劃 $9 的那一層,對方免費;我規劃 $39 的那一層,對方 $9.99。在「更便宜的 bundle App」這條路上,我沒有立足點。

那份研究同時給了出路:對方最貴的方案(全部是報表)沒有一項在回答「有沒有東西壞掉」,而所有競品的最嚴重差評都是這一類。所以定位從「更便宜」改成「比較貴,因為賣的是不同的東西」,目標客群也跟著換成已經被燒過、且有實質營收在賭桌上的商家。

對這些人,月費差幾塊錢不是重點,「不要靜默虧錢」才是。

現況與停損線

功能完成,全鏈路實測通過,待上架送審。

我在動工之前就把「不行」寫死了,因為不事先定義,「不行就打掉」不會變成決定,只會變成無限期的沉沒成本:

  • 上架後 3 個月判定,不延長
  • 五項指標:安裝數、掃描後發現至少一個真實衝突的商店比例、免費轉付費率、MRR、30 天留存
  • 任兩項落在「收掉」欄就收掉。不重新詮釋、不找理由。
  • 總投入上限 $300
  • 真的要收,先寄信通知既有用戶並給至少 30 天——有一家競品零預警下架,害慘了正在用它的商家,我不想變成自己批判的東西

其中第二項是整件事的核心假設:折扣衝突到底有多普遍。 免費的掃描功能本身就是這個假設的量測工具,而且樣本數比任何訪談計畫都大。

如果那個數字很低,那就是這個產品沒有存在必要——那也是一個答案。

04聯絡
所在地
新北,台灣 · UTC+8
狀態
2026年6月畢業 · 軟體工程職缺洽談中

說點具體的

寄信最快。我會在一個工作天內回覆,通常帶著比答案更多的問題。

或直接在這裡留言
Gray Tsao© 2026 · 最後更新 2026年9月