[object Object]

← back to Designer Wallcoverings

dw-free-samples: add block-nonpurchasable-checkout validation function (TK-10825/TK-10010 — reject quote-only+discontinued at checkout, tag-scoped, samples exempt); 7/7 tests, wasm builds, schema-valid vs 2025-10

67cd4ff994d47de67baf18273c361f2bc06c3685 · 2026-08-25 07:46:42 -0700 · steve

Files touched

Diff

commit 67cd4ff994d47de67baf18273c361f2bc06c3685
Author: steve <steve@designerwallcoverings.com>
Date:   Tue Aug 25 07:46:42 2026 -0700

    dw-free-samples: add block-nonpurchasable-checkout validation function (TK-10825/TK-10010 — reject quote-only+discontinued at checkout, tag-scoped, samples exempt); 7/7 tests, wasm builds, schema-valid vs 2025-10
---
 .../block-nonpurchasable-checkout/src/index.js     |  3 +
 .../block-nonpurchasable-checkout/src/run.graphql  | 28 +++++++++
 .../block-nonpurchasable-checkout/src/run.js       | 63 +++++++++++++++++++
 .../block-nonpurchasable-checkout/src/run.test.js  | 73 ++++++++++++++++++++++
 .../block-nonpurchasable-checkout/vite.config.js   |  1 +
 5 files changed, 168 insertions(+)

diff --git a/shopify/staged/free-samples-function/extensions/block-nonpurchasable-checkout/src/index.js b/shopify/staged/free-samples-function/extensions/block-nonpurchasable-checkout/src/index.js
new file mode 100644
index 00000000..8030ce88
--- /dev/null
+++ b/shopify/staged/free-samples-function/extensions/block-nonpurchasable-checkout/src/index.js
@@ -0,0 +1,3 @@
+// Function entry point. The CLI's `function build` bundles this module and
+// requires the target's export (`run`, per shopify.extension.toml) to be visible here.
+export { run } from './run.js';
diff --git a/shopify/staged/free-samples-function/extensions/block-nonpurchasable-checkout/src/run.graphql b/shopify/staged/free-samples-function/extensions/block-nonpurchasable-checkout/src/run.graphql
index 68beeaa5..ced50309 100644
--- a/shopify/staged/free-samples-function/extensions/block-nonpurchasable-checkout/src/run.graphql
+++ b/shopify/staged/free-samples-function/extensions/block-nonpurchasable-checkout/src/run.graphql
@@ -2,6 +2,34 @@ query Input {
   cart {
     lines {
       id
+      merchandise {
+        __typename
+        ... on ProductVariant {
+          title
+          sampleMeta: metafield(namespace: "custom", key: "is_sample") {
+            value
+          }
+          product {
+            handle
+            title
+            # Only ask Shopify whether each NON-PURCHASABLE tag is present (cheap, exact).
+            hasTags(
+              tags: [
+                "quote-only"
+                "Quote Only"
+                "quote_only"
+                "Quote-Only"
+                "Discontinued"
+                "Discontinued-Review"
+                "YB-Discontinued-2026-04"
+              ]
+            ) {
+              tag
+              hasTag
+            }
+          }
+        }
+      }
     }
   }
 }
diff --git a/shopify/staged/free-samples-function/extensions/block-nonpurchasable-checkout/src/run.js b/shopify/staged/free-samples-function/extensions/block-nonpurchasable-checkout/src/run.js
new file mode 100644
index 00000000..b0b9f2c5
--- /dev/null
+++ b/shopify/staged/free-samples-function/extensions/block-nonpurchasable-checkout/src/run.js
@@ -0,0 +1,63 @@
+// @ts-check
+//
+// DW Block Non-Purchasable Checkout — Cart & Checkout Validation Function.
+// Target: cart.validations.generate.run
+//
+// Blocks checkout of line items whose product is NOT purchasable, server-side
+// (storefront/theme guards can be bypassed via /cart add, API, permalink):
+//   - TK-10825: quote-only products (buyable at $0 today, availableForSale=true)
+//   - TK-10010: discontinued products that slip back to orderable
+//
+// CORRECTNESS RULES (codex-reviewed — do not "improve" into a price check):
+//   - Reject ONLY on the non-purchasable TAG (quote-only* / Discontinued*).
+//   - NEVER reject on price==$0 alone — legitimate free samples/gifts exist.
+//   - NEVER block a SAMPLE line (variant title "Sample" OR custom.is_sample=="true").
+//
+// Result shape: operations[].validationAdd.errors[] — each {message, target}
+// BLOCKS checkout with the message pinned to the offending cart line.
+
+/**
+ * @typedef {import("../generated/api").CartValidationsGenerateRunResult} RunResult
+ */
+
+const DISCONTINUED_RE = /discontinued/i;
+
+/** @param {any} variant */
+function isSampleLine(variant) {
+  if (!variant) return false;
+  if (/sample/i.test(variant.title || "")) return true;
+  if (variant.sampleMeta && String(variant.sampleMeta.value).toLowerCase() === "true") return true;
+  return false;
+}
+
+/**
+ * @param {any} input
+ * @returns {RunResult}
+ */
+export function run(input) {
+  const lines = input?.cart?.lines ?? [];
+  /** @type {{message: string, target: string}[]} */
+  const errors = [];
+
+  lines.forEach((line, index) => {
+    const variant = line?.merchandise;
+    // Only ProductVariant lines carry a product/tags; skip custom/gift-card lines.
+    if (!variant || variant.__typename !== "ProductVariant") return;
+    if (isSampleLine(variant)) return; // samples are always allowed
+
+    const hasTags = variant.product?.hasTags ?? [];
+    const present = hasTags.find((t) => t && t.hasTag);
+    if (!present) return; // purchasable — no non-purchasable tag present
+
+    const discontinued = DISCONTINUED_RE.test(present.tag || "");
+    errors.push({
+      message: discontinued
+        ? "This item has been discontinued and can no longer be ordered. Please remove it to continue."
+        : "This item is available by quote only — please request a quote instead of purchasing it directly.",
+      // Line-scoped target so the error attaches to the offending line at checkout.
+      target: `$.cart.lines[${index}]`,
+    });
+  });
+
+  return { operations: errors.length ? [{ validationAdd: { errors } }] : [] };
+}
diff --git a/shopify/staged/free-samples-function/extensions/block-nonpurchasable-checkout/src/run.test.js b/shopify/staged/free-samples-function/extensions/block-nonpurchasable-checkout/src/run.test.js
new file mode 100644
index 00000000..2a2c5482
--- /dev/null
+++ b/shopify/staged/free-samples-function/extensions/block-nonpurchasable-checkout/src/run.test.js
@@ -0,0 +1,73 @@
+import { describe, it } from "node:test";
+import assert from "node:assert";
+import { run } from "./run.js";
+
+// Helper: build a ProductVariant cart line. `tags` = array of tags that ARE present.
+function line(index, { title = "Roll", tags = [], sample = false, priceZero = false, typename = "ProductVariant" } = {}) {
+  const ALL = ["quote-only", "Quote Only", "quote_only", "Quote-Only", "Discontinued", "Discontinued-Review", "YB-Discontinued-2026-04"];
+  return {
+    id: `gid://shopify/CartLine/${index}`,
+    merchandise: {
+      __typename: typename,
+      title: sample ? "Sample" : title,
+      sampleMeta: sample ? { value: "true" } : null,
+      // price is intentionally NOT read by run.js; priceZero only documents intent.
+      product: {
+        handle: `p-${index}`,
+        title: `Product ${index}`,
+        hasTags: ALL.map((t) => ({ tag: t, hasTag: tags.includes(t) })),
+      },
+    },
+  };
+}
+
+function errorsFor(lines) {
+  const res = run({ cart: { lines } });
+  return res.operations.length ? res.operations[0].validationAdd.errors : [];
+}
+
+describe("block-nonpurchasable-checkout", () => {
+  it("BLOCKS a quote-only line", () => {
+    const errs = errorsFor([line(0, { tags: ["quote-only"] })]);
+    assert.equal(errs.length, 1);
+    assert.match(errs[0].message, /quote only/i);
+    assert.equal(errs[0].target, "$.cart.lines[0]");
+  });
+
+  it("BLOCKS a discontinued line (any discontinued tag variant)", () => {
+    for (const t of ["Discontinued", "Discontinued-Review", "YB-Discontinued-2026-04"]) {
+      const errs = errorsFor([line(0, { tags: [t] })]);
+      assert.equal(errs.length, 1, `expected block for ${t}`);
+      assert.match(errs[0].message, /discontinued/i);
+    }
+  });
+
+  it("ALLOWS a normal priced product (no non-purchasable tag)", () => {
+    assert.equal(errorsFor([line(0, { tags: [] })]).length, 0);
+  });
+
+  it("ALLOWS a $0 SAMPLE line even though it is free (never block samples)", () => {
+    // sample by variant title, and also sample by metafield — both allowed even if tagged
+    assert.equal(errorsFor([line(0, { sample: true, priceZero: true })]).length, 0);
+    assert.equal(errorsFor([line(0, { sample: true, tags: ["quote-only"] })]).length, 0);
+  });
+
+  it("ALLOWS a $0 NON-tagged line (price alone must never block)", () => {
+    assert.equal(errorsFor([line(0, { priceZero: true, tags: [] })]).length, 0);
+  });
+
+  it("skips non-ProductVariant lines (custom/gift-card) without error", () => {
+    assert.equal(errorsFor([line(0, { typename: "CustomProduct", tags: ["quote-only"] })]).length, 0);
+  });
+
+  it("blocks only the offending lines in a mixed cart, with correct indexes", () => {
+    const errs = errorsFor([
+      line(0, { tags: [] }),               // ok
+      line(1, { tags: ["Discontinued"] }), // block
+      line(2, { sample: true }),           // ok (sample)
+      line(3, { tags: ["quote_only"] }),   // block
+    ]);
+    assert.equal(errs.length, 2);
+    assert.deepEqual(errs.map((e) => e.target).sort(), ["$.cart.lines[1]", "$.cart.lines[3]"]);
+  });
+});
diff --git a/shopify/staged/free-samples-function/extensions/block-nonpurchasable-checkout/vite.config.js b/shopify/staged/free-samples-function/extensions/block-nonpurchasable-checkout/vite.config.js
new file mode 100644
index 00000000..5a2b3966
--- /dev/null
+++ b/shopify/staged/free-samples-function/extensions/block-nonpurchasable-checkout/vite.config.js
@@ -0,0 +1 @@
+// Prevents inheritance from any parent project config; the CLI bundles src/index.js.

← b466ef20 fix: align hasDesc threshold with bodyHtmlValid (> 0 not >=  ·  back to Designer Wallcoverings  ·  PJ Fall 2026 Collection mailer + CC campaign builder (draft, 26457c97 →