[object Object]

← back to Dw Checkout Guard

scaffold DW Checkout Guard validation function (rejects $0 or discontinued line items)

1ba0a3e537f9487bca8ec76cb556d9357e6d6c1a · 2026-07-30 08:42:26 -0700 · steve-office

Files touched

Diff

commit 1ba0a3e537f9487bca8ec76cb556d9357e6d6c1a
Author: steve-office <steve@designerwallcoverings.com>
Date:   Thu Jul 30 08:42:26 2026 -0700

    scaffold DW Checkout Guard validation function (rejects $0 or discontinued line items)
---
 .gitignore                                         |   9 ++
 README.md                                          | 113 +++++++++++++++++++++
 .../dw-checkout-guard/shopify.extension.toml       |  26 +++++
 .../src/cart_validations_generate_run.graphql      |  29 ++++++
 .../src/cart_validations_generate_run.js           |  77 ++++++++++++++
 package.json                                       |  21 ++++
 shopify.app.toml                                   |  21 ++++
 7 files changed, 296 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..78113b1
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,9 @@
+node_modules/
+.env*
+tmp/
+*.log
+.DS_Store
+dist/
+build/
+.next/
+.shopify/
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..5fd4feb
--- /dev/null
+++ b/README.md
@@ -0,0 +1,113 @@
+# DW Checkout Guard
+
+A Shopify **Cart & Checkout Validation Function** for the LIVE DW store
+(`designer-laboratory-sandbox.myshopify.com`). It blocks the "Checkout" button
+whenever a cart contains a line item that is either:
+
+1. **$0.00** — the quote-only "$0 Standard variant" exposure (~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 (`Discontinued`,
+   `Discontinued-Review`, `YB-Discontinued-2026-04`). Belt-and-suspenders: the 42
+   known sellable-discontinued items were already drafted 2026-07-30.
+
+## Why a Validation Function (and not per-variant `available:false`)
+
+Canary-proven 2026-07-30: this store does **not** gate `/cart/add.js` on
+inventory. Setting the $0 variant to `available:false` (deny + qty 0) still lets
+the $0 line be added (tested at qty 1 and qty 5). Only a **checkout-layer
+validation** reliably blocks the $0 *order* — and, unlike drafting, it does so
+**without delisting the product**, so the intended `$4.25` sample flow keeps
+working. (Discontinued items get drafted instead, since they should be fully off
+the store.) See memory `dw-shopify-addjs-inventory-not-gated`.
+
+**Cost:** $0 — Shopify Functions have no per-invocation charge; the custom app is
+free.
+
+---
+
+## The two files that matter
+
+Everything else here is boilerplate. The business logic lives in exactly two
+files, and they are stable across CLI versions:
+
+- `extensions/dw-checkout-guard/src/cart_validations_generate_run.graphql` — the
+  input query (reads each line's per-unit `cost` + the product's discontinued tag).
+- `extensions/dw-checkout-guard/src/cart_validations_generate_run.js` — the rule
+  (`amount <= 0 || isDiscontinued` → add a blocking error scoped to that line).
+
+---
+
+## Deploy — RECOMMENDED path (version-proof)
+
+Because the exact `api_version` / target string can drift between `shopify` CLI
+versions, the safest path is to let the CLI scaffold the boilerplate, then paste
+in the two logic files above:
+
+```sh
+cd ~/Projects/dw-checkout-guard
+
+# 1. Log in to the DW Partner org (opens a browser)
+shopify auth login
+
+# 2. Link (or create) the app under the Partner org
+shopify app config link          # fills client_id in shopify.app.toml
+
+# 3. Generate a Cart & Checkout Validation function extension
+shopify app generate extension
+#   → pick:  Function  →  "Cart and checkout validation"
+#   → language: JavaScript
+#   → name it e.g. dw-checkout-guard
+
+# 4. Overwrite the generated src/ logic with the two files from THIS repo:
+#      extensions/dw-checkout-guard/src/cart_validations_generate_run.graphql
+#      extensions/dw-checkout-guard/src/cart_validations_generate_run.js
+#    (match the generated filenames/export name if the CLI used different ones —
+#     the rule inside the run() function is what you're copying.)
+
+# 5. Build + deploy
+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.)
+
+## Deploy — as-is path
+
+If your `shopify` CLI matches `api_version = "2025-01"` and the
+`cart.validations.generate.run` target, this repo is already complete — just:
+
+```sh
+cd ~/Projects/dw-checkout-guard
+shopify auth login
+shopify app config link
+shopify app deploy
+```
+
+---
+
+## Verify after deploy (no real order needed)
+
+The exposure reproduces only through the cart endpoint, so test there:
+
+```sh
+# $0 quote-only variant should now be BLOCKED at checkout.
+# Add it, then try to advance to checkout — the guard message should appear.
+curl -s -X POST https://www.designerwallcoverings.com/cart/add.js \
+  -H 'Content-Type: application/json' \
+  --data '{"id":44494274199603,"quantity":1}'      # $0 "Standard" variant
+# (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).
+
+## Rollback
+
+Turn the validation off in **Settings → Checkout**, or `shopify app deploy` a
+version with the function removed. No catalog data is touched by this function.
diff --git a/extensions/dw-checkout-guard/shopify.extension.toml b/extensions/dw-checkout-guard/shopify.extension.toml
new file mode 100644
index 0000000..f31e1c1
--- /dev/null
+++ b/extensions/dw-checkout-guard/shopify.extension.toml
@@ -0,0 +1,26 @@
+# DW Checkout Guard — Cart & Checkout Validation Function extension
+# API version pinned to a recent stable release. If your `shopify` CLI scaffolds
+# a different api_version / target string, prefer the CLI's values (see README)
+# and only paste in src/cart_validations_generate_run.{js,graphql}.
+api_version = "2025-01"
+
+[[extensions]]
+name = "DW Checkout Guard"
+handle = "dw-checkout-guard"
+type = "function"
+
+  [[extensions.targeting]]
+  # Cart & Checkout Validation target (2025-01). The run() export name must
+  # match the target: cart.validations.generate.run -> cartValidationsGenerateRun
+  target = "cart.validations.generate.run"
+  input_query = "src/cart_validations_generate_run.graphql"
+  export = "cartValidationsGenerateRun"
+
+  [extensions.build]
+  # Built by `shopify app function build` (javy) -> dist/function.wasm
+  path = "dist/function.wasm"
+  watch = ["src/**/*.js", "src/**/*.graphql"]
+
+  [extensions.ui.paths]
+  create = "/"
+  details = "/"
diff --git a/extensions/dw-checkout-guard/src/cart_validations_generate_run.graphql b/extensions/dw-checkout-guard/src/cart_validations_generate_run.graphql
new file mode 100644
index 0000000..124e882
--- /dev/null
+++ b/extensions/dw-checkout-guard/src/cart_validations_generate_run.graphql
@@ -0,0 +1,29 @@
+query CartValidationsGenerateRun {
+  cart {
+    lines {
+      quantity
+      # per-unit price of the line; $0.00 is the exposure we block
+      cost {
+        amountPerQuantity {
+          amount
+        }
+      }
+      merchandise {
+        __typename
+        ... on ProductVariant {
+          id
+          title
+          product {
+            id
+            title
+            # true if the product carries any discontinued tag (belt-and-suspenders;
+            # the 42 known sellable-discontinued were already drafted 2026-07-30)
+            isDiscontinued: hasAnyTag(
+              tags: ["Discontinued", "Discontinued-Review", "YB-Discontinued-2026-04"]
+            )
+          }
+        }
+      }
+    }
+  }
+}
diff --git a/extensions/dw-checkout-guard/src/cart_validations_generate_run.js b/extensions/dw-checkout-guard/src/cart_validations_generate_run.js
new file mode 100644
index 0000000..c33bf0b
--- /dev/null
+++ b/extensions/dw-checkout-guard/src/cart_validations_generate_run.js
@@ -0,0 +1,77 @@
+// @ts-check
+//
+// DW Checkout Guard — Cart & Checkout Validation Function
+// -------------------------------------------------------
+// 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
+//       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;
+//       the 42 known sellable-discontinued items were drafted 2026-07-30).
+//
+// 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
+//   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).
+
+/**
+ * @typedef {{ amount: string }} Money
+ * @typedef {{
+ *   quantity: number,
+ *   cost?: { amountPerQuantity?: Money | null } | null,
+ *   merchandise: {
+ *     __typename: string,
+ *     id?: string,
+ *     title?: string | null,
+ *     product?: { id?: string, title?: string | null, isDiscontinued?: boolean } | null,
+ *   }
+ * }} CartLine
+ * @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.";
+
+/**
+ * @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 = [];
+
+  const lines = input?.cart?.lines ?? [];
+  lines.forEach((line, index) => {
+    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);
+
+    // (1) $0.00 line — parse the per-unit cost; treat missing/NaN as 0 (block, don't leak)
+    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}]`,
+      });
+    }
+  });
+
+  return {
+    operations: errors.length > 0 ? [{ validationAdd: { errors } }] : [],
+  };
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..b27d48e
--- /dev/null
+++ b/package.json
@@ -0,0 +1,21 @@
+{
+  "name": "dw-checkout-guard",
+  "version": "1.0.0",
+  "private": true,
+  "description": "Shopify Cart & Checkout Validation Function that rejects $0 or discontinued line items on the DW live store.",
+  "license": "UNLICENSED",
+  "type": "module",
+  "scripts": {
+    "dev": "shopify app dev",
+    "build": "shopify app build",
+    "deploy": "shopify app deploy",
+    "function:build": "shopify app function build",
+    "function:typegen": "shopify app function typegen"
+  },
+  "dependencies": {
+    "@shopify/shopify_function": "^1.0.0"
+  },
+  "devDependencies": {
+    "@shopify/cli": "^3.66.0"
+  }
+}
diff --git a/shopify.app.toml b/shopify.app.toml
new file mode 100644
index 0000000..0ed3705
--- /dev/null
+++ b/shopify.app.toml
@@ -0,0 +1,21 @@
+# DW Checkout Guard — Shopify app config
+# ---------------------------------------
+# `client_id` is filled automatically the first time you run
+# `shopify app config link` (or `shopify app deploy`, which will prompt you to
+# create/select an app under the Designer Wallcoverings Partner org).
+# Leave client_id blank until then.
+client_id = ""
+
+name = "DW Checkout Guard"
+application_url = "https://designer-laboratory-sandbox.myshopify.com"
+embedded = true
+
+[access_scopes]
+# A validation Function needs no admin scopes — it reads only the cart at runtime.
+scopes = ""
+
+[webhooks]
+api_version = "2025-01"
+
+[build]
+include_config_on_deploy = true

(oldest)  ·  back to Dw Checkout Guard  ·  harden checkout-guard per contrarian: $.cart target, fail-op 7b5d844 →