前言

同一個 Shopify 折扣活動如果同時用於 Online Store 與 POS,商家通常會希望有一個開關:網路商店維持原本折扣,但實體門市可以選擇是否套用。

第一個問題是,Discount Function 怎麼知道這次 Cart 來自 POS?

第二個更容易被忽略的問題是:知道「這是實體零售交易」,是否就等於知道「這間門市使用 POS Pro」?

這次實作先加入 cart.retailLocation 判斷,再用 schema version 保護既有設定。後續精簡 Function schema 時,又暴露出版本條件沒有一起演進的缺口。這個過程很適合說明:可查詢的 runtime signal、產品文案與真正可保證的方案資格,必須分開。

上一篇先整理了 折扣跨商品頁、Cart、Checkout 與 POS 的責任邊界,這篇專注在 POS。

原本的做法與問題症狀

最早的 Function 不查詢零售地點。只要 Product、Collection 與活動規則命中,Online Store 和 POS 都會得到相同折扣。

後台後來加入一個「適用 POS」選項,並把布林值保存在 Promotion。若 Function input 沒有任何 surface signal,這個選項只是 UI 狀態,無法改變結帳結果。

另一個不適合的方向,是在 Function 裡判斷商家是否訂閱特定 POS 方案。Discount Function 是受限制、可重現的 runtime,不會自動取得 Partner 後台的方案資訊;Function schema 提供的是 Cart 與零售地點資料,不是門市訂閱帳務。

因此,如果把開關命名成「只在 POS Pro 套用」,容易讓程式承諾超過證據範圍的能力。

問題是怎麼推敲出來的?

Shopify 在 2025-07 Function API 加入 Cart.retailLocation,官方定義是建立或完成零售訂單的 physical location。

這個欄位很適合回答:

這個 Cart 是否處於實體零售環境?

它不能單獨回答:

這個 Location 是否訂閱某一種 POS 方案?

兩者是不同資料。

本地 Function input query 最後加入:

cart {
  retailLocation {
    id
  }
}

Online cart 測試使用 retailLocation: null;POS fixture 則提供 Location GID。Function 因此能把「沒有零售地點」與「有零售地點」分開處理。

最後採用的架構

當時的規則設計是:

retailLocation 不存在
    └─ 視為 Online cart,維持原折扣

retailLocation 存在
    ├─ Promotion 允許 POS → 執行折扣
    └─ Promotion 不允許 POS → 回傳空操作

為避免新欄位讓舊設定突然停止 POS 折扣,Function config 增加 schemaVersion

舊版沒有 schema version 時採相容行為,繼續允許;schema v2 才正式讀取 posEligibility。這是一種漸進 rollout:先部署能理解新舊格式的 Function,再逐步重同步 Promotion 設定。

重要的是,這個架構實際判斷的是「零售 Cart 是否允許折扣」,不是向 Shopify 查詢 POS plan entitlement。

關鍵實作

當時實際部署的核心條件很短:

function isRetailDiscountAllowed(configuration, retailLocation) {
  if (!retailLocation) return true;
  if (configuration.schemaVersion !== 2) return true;

  return configuration.posEligibility?.appliesOnPosPro === true;
}

Function 在任何商品規則運算前先呼叫它;不符合時直接回傳:

{operations: []}

這能維持 fail closed:零售 Cart 明確不允許時,不產生候選折扣,而不是先建立 candidates 再嘗試從結果刪除。

如果產品需求是只允許特定門市,retailLocation.id 也能和設定中的 Location GID allowlist 比對。但 Location IDs 必須由 Web App 透過 Admin API 管理並寫入 compact config,不能在 Function 中臨時呼叫外部服務。

實際驗證

原始 POS 測試與 Wasm fixtures 覆蓋:

  • Online cart 在 POS 開關為 true 或 false 時都維持折扣。
  • retailLocation 且關閉 POS eligibility 時回傳空操作。
  • retailLocation 且開啟 POS eligibility 時產生折扣。
  • 沒有 schema version 的 legacy config 維持舊行為。
  • Function input fixture 確實包含零售 Location GID。

但研究最新版程式時發現一個重要回歸風險:compact config 已升到 schema v3,並仍保存 posEligibility;實際 gating function 卻以「版本必須剛好等於 2」才套用檢查。也就是說,schema v3 的 POS Cart 目前會走相容分支直接允許。

現有 schema v3 tests 只驗證 Collection membership,沒有同時帶入 retailLocation 檢查 POS 關閉情境。這代表早期 v2 測試通過,不能證明後續 schema migration 仍維持同一行為。

仍然存在的限制

目前最直接的限制,是 schema v3 的 POS eligibility 尚未被測試與正確套用。安全修正應讓所有支援 posEligibility 的新版 schema 共用規則,例如將第二個條件改為 schemaVersion < 2,或直接檢查欄位能力,再加入 v3 POS on/off fixtures。

這篇記錄的是已定位的設計與回歸缺口,不應被解讀成目前正式 App 已完整保證 POS gating。

其次,retailLocation 只能證明 physical retail context。若產品文案承諾「僅 POS Pro」,仍需要另一個經 Shopify 官方支援、可查核且能對應 Location 的方案資料來源;在沒有這個證據前,較準確的名稱是「允許 POS/實體門市折扣」。

Function fixture 也不能取代真實 Shopify POS App 測試。正式驗收仍要在允許與不允許的門市各建立控制 Cart,確認 Function app version、discount node 與設定都已同步。

最後,POS 與 Online Store 可能使用同一個 Automatic App Discount。任何 POS 修正都屬於 Shopify Function app version,不能只部署 Web App。

可以延伸到哪些情境?

retailLocation 不只能做總開關,還可以延伸到:

  • 特定門市限定折扣。
  • 不同地點使用不同促銷。
  • Online-only 或 retail-only 活動。
  • 對無 Location 的 Cart 採明確預設策略。

工程上更通用的提醒是:每次增加 schemaVersion,測試矩陣都要包含所有既有 capability,而不是只測這次新增的欄位。Schema migration 是整份 runtime contract 的變更。

下一篇會回到 App 後台權限:Shopify App 收費不能只做付款頁:如何限制未訂閱帳號?

參考資料