← 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
A shopify/staged/free-samples-function/extensions/block-nonpurchasable-checkout/src/index.jsM shopify/staged/free-samples-function/extensions/block-nonpurchasable-checkout/src/run.graphqlA shopify/staged/free-samples-function/extensions/block-nonpurchasable-checkout/src/run.jsA shopify/staged/free-samples-function/extensions/block-nonpurchasable-checkout/src/run.test.jsA shopify/staged/free-samples-function/extensions/block-nonpurchasable-checkout/vite.config.js
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 →