[object Object]

← back to Dw Checkout Guard

harden checkout-guard per contrarian: $.cart target, fail-open on unreadable price, mandatory activate+typegen steps, mixed-cart regression + headless caveat

7b5d844a82337a30be0639886009f103cf49926b · 2026-07-30 08:48:24 -0700 · steve-office

Files touched

Diff

commit 7b5d844a82337a30be0639886009f103cf49926b
Author: steve-office <steve@designerwallcoverings.com>
Date:   Thu Jul 30 08:48:24 2026 -0700

    harden checkout-guard per contrarian: $.cart target, fail-open on unreadable price, mandatory activate+typegen steps, mixed-cart regression + headless caveat
---
 README.md                                          | 50 +++++++++++++--
 .../src/cart_validations_generate_run.js           | 75 ++++++++++++++--------
 2 files changed, 93 insertions(+), 32 deletions(-)

diff --git a/README.md b/README.md
index 5fd4feb..cd7550b 100644
--- a/README.md
+++ b/README.md
@@ -71,10 +71,37 @@ shopify app function typegen      # optional, regenerates types
 shopify app deploy                # pushes the function version live
 ```
 
-After `deploy`, activate it in Admin:
-**Settings → Checkout → (Validations / "Manage checkout rules")** → add
-**DW Checkout Guard** → turn it on. (Some stores auto-enable a single validation
-on deploy; confirm it's toggled on.)
+### ⚠️ MANDATORY post-deploy step — ACTIVATE the validation
+
+`shopify app deploy` uploads the function but **does NOT enforce it** until you
+turn it on. A deployed-but-inactive validation lets every $0 line straight
+through — it looks done and isn't. So this is step 1, not a footnote:
+
+**Admin → Settings → Checkout → Validations (a.k.a. "Manage checkout rules")**
+→ add **DW Checkout Guard** → **toggle it ON**.
+
+Then confirm it's actually enforcing with the smoke test in "Verify after deploy"
+below — do not consider this shipped until a $0 line is provably blocked at the
+Checkout step in a real browser.
+
+### ⚠️ MANDATORY pre-deploy step — confirm the cost field name
+
+The one field this function depends on is the per-unit line price. Before you
+deploy, confirm the Function input schema actually exposes it (schemas differ
+from the Storefront API and can shift between `api_version`s):
+
+```sh
+shopify app function typegen
+# open the generated types for CartLineCost and confirm `amountPerQuantity`
+# exists. If the field is named differently in your api_version, update
+# src/cart_validations_generate_run.graphql to match.
+```
+
+If the field name is wrong, `shopify app function build` FAILS LOUDLY with an
+"unknown field" error before anything reaches the store — so a bad field can't
+silently ship. And even at runtime the JS **fails open** (an unreadable price is
+skipped, never treated as $0), so the worst case is "the $0 block doesn't fire,"
+never "all checkout blocked."
 
 ## Deploy — as-is path
 
@@ -103,9 +130,18 @@ curl -s -X POST https://www.designerwallcoverings.com/cart/add.js \
 # (add.js still succeeds — that's expected; the block is at the CHECKOUT step)
 ```
 
-Then, in a browser cart with that line, click **Checkout** — you should see the
-guard message and be unable to proceed. A normal-priced line + the `$4.25`
-sample must still check out fine (regression check).
+Then, in a browser cart, verify all three cases:
+1. **$0 line alone** → click Checkout → blocked with the guard message. ✅
+2. **Mixed cart** ($0 line + a $4.25 sample) → blocked while the $0 line is
+   present; **remove the $0 line** → the sample alone checks out fine. ✅
+   (This proves the guard doesn't collateral-block the sample flow.)
+3. **A normal-priced product alone** → checks out fine (no false positive). ✅
+
+**Scope caveat:** Cart & Checkout Validation functions run on the standard
+browser checkout AND draft orders — which covers this exposure (the `/cart/add.js`
+vector). They do **not** necessarily run on a fully headless/custom Storefront-API
+checkout or a Subscriptions-app flow. DW doesn't use those today; revisit this
+guard if that ever changes.
 
 ## Rollback
 
diff --git a/extensions/dw-checkout-guard/src/cart_validations_generate_run.js b/extensions/dw-checkout-guard/src/cart_validations_generate_run.js
index c33bf0b..5742d46 100644
--- a/extensions/dw-checkout-guard/src/cart_validations_generate_run.js
+++ b/extensions/dw-checkout-guard/src/cart_validations_generate_run.js
@@ -4,7 +4,7 @@
 // -------------------------------------------------------
 // Rejects any cart line that is either:
 //   (1) priced $0.00  — the quote-only "$0 Standard variant" exposure
-//       (1,744 Phillipe Romano / Fentucci Naturals products whose sellable
+//       (~1,744 Phillipe Romano / Fentucci Naturals products whose sellable
 //       variant is $0 + available:true and is addable via /cart/add.js even
 //       though the PDP button is hidden), OR
 //   (2) discontinued  — a product carrying a discontinued tag (future-proofing;
@@ -12,14 +12,25 @@
 //
 // Why a Validation Function and not per-variant availability:
 //   The live store does NOT gate /cart/add.js on inventory — setting a variant
-//   to available:false (deny + qty 0) still lets the $0 line be added (proven
+//   available:false (deny + qty 0) still lets the $0 line be added (proven
 //   2026-07-30). Only a checkout-layer validation reliably blocks the $0 order
 //   without delisting the product (these lines must stay live for the $4.25
 //   sample flow). See memory: dw-shopify-addjs-inventory-not-gated.
 //
-// This function runs at cart + checkout. When it returns any error, Shopify
-// blocks the "Checkout"/"Continue to checkout" button until the offending line
-// is removed. It is FREE to run (Shopify Functions have no per-invocation cost).
+// DESIGN NOTES (after /contrarian review, TK-10050):
+//   * Error target is "$.cart" — the validation function does NOT support
+//     line-level targets like "$.cart.lines[N]"; those are silently dropped.
+//     We emit ONE consolidated blocking error on the whole cart.
+//   * FAIL OPEN on an unreadable price. A missing/NaN cost field is an
+//     infra/schema fault, NOT evidence of $0 — blocking on it would break
+//     checkout for EVERY line. So we only block when the price parses to a
+//     real number <= 0. (Worst case if the cost field is wrong: the $0 block
+//     silently doesn't fire — a known-gap false negative, never a store-wide
+//     false positive. The build step + README typegen check catch a bad field.)
+//   * We read cost.amountPerQuantity (per-UNIT merchandise price), which is
+//     discount-INDEPENDENT — so a 100%-off code / free-gift promo does NOT read
+//     as $0 here and is never falsely blocked. (totalAmount would be wrong.)
+//   * This function is FREE to run (Shopify Functions have no per-invocation cost).
 
 /**
  * @typedef {{ amount: string }} Money
@@ -36,42 +47,56 @@
  * @typedef {{ cart: { lines: CartLine[] } }} RunInput
  */
 
-const ZERO_PRICE_MESSAGE =
-  "This item can't be purchased online — please request a quote or remove it to continue to checkout.";
-const DISCONTINUED_MESSAGE =
-  "This item has been discontinued and can no longer be ordered — please remove it to continue to checkout.";
+const ZERO_ONLY_MESSAGE =
+  "Your cart contains an item that can't be purchased online — please request a quote or remove it to continue to checkout.";
+const DISCONTINUED_ONLY_MESSAGE =
+  "Your cart contains a discontinued item that can no longer be ordered — please remove it to continue to checkout.";
+const MIXED_MESSAGE =
+  "Your cart contains items that can't be ordered online (discontinued or quote-only) — please remove them to continue to checkout.";
 
 /**
  * @param {RunInput} input
  * @returns {{ operations: Array<{ validationAdd: { errors: Array<{ message: string, target: string }> } }> }}
  */
 export function cartValidationsGenerateRun(input) {
-  /** @type {Array<{ message: string, target: string }>} */
-  const errors = [];
+  let anyDiscontinued = false;
+  let anyZeroPriced = false;
 
   const lines = input?.cart?.lines ?? [];
-  lines.forEach((line, index) => {
+  for (const line of lines) {
     const merchandise = line.merchandise;
     const isVariant = merchandise && merchandise.__typename === "ProductVariant";
 
     // (2) discontinued — only meaningful for a real product variant
-    const isDiscontinued = Boolean(isVariant && merchandise.product?.isDiscontinued);
+    if (isVariant && merchandise.product?.isDiscontinued) {
+      anyDiscontinued = true;
+      continue; // already offending; no need to also price-check this line
+    }
 
-    // (1) $0.00 line — parse the per-unit cost; treat missing/NaN as 0 (block, don't leak)
+    // (1) $0.00 line — parse the per-unit cost. FAIL OPEN: only block when the
+    // price parses to a finite number <= 0; a missing/NaN field is skipped.
     const rawAmount = line?.cost?.amountPerQuantity?.amount;
-    const amount = rawAmount == null ? 0 : Number.parseFloat(rawAmount);
-    const isZeroPriced = !Number.isFinite(amount) || amount <= 0;
-
-    if (isDiscontinued || isZeroPriced) {
-      errors.push({
-        message: isDiscontinued ? DISCONTINUED_MESSAGE : ZERO_PRICE_MESSAGE,
-        // JSON-path target: scope the error to the specific offending line
-        target: `$.cart.lines[${index}]`,
-      });
+    if (rawAmount != null) {
+      const amount = Number.parseFloat(rawAmount);
+      if (Number.isFinite(amount) && amount <= 0) {
+        anyZeroPriced = true;
+      }
     }
-  });
+  }
+
+  if (!anyDiscontinued && !anyZeroPriced) {
+    return { operations: [] };
+  }
+
+  const message =
+    anyDiscontinued && anyZeroPriced
+      ? MIXED_MESSAGE
+      : anyDiscontinued
+        ? DISCONTINUED_ONLY_MESSAGE
+        : ZERO_ONLY_MESSAGE;
 
+  // One consolidated blocking error on the supported "$.cart" target.
   return {
-    operations: errors.length > 0 ? [{ validationAdd: { errors } }] : [],
+    operations: [{ validationAdd: { errors: [{ message, target: "$.cart" }] } }],
   };
 }

← 1ba0a3e scaffold DW Checkout Guard validation function (rejects $0 o  ·  back to Dw Checkout Guard  ·  auto-data-snapshot: 2026-08-25T08:16:28 (9 data files) — ext bd7e501 →