[object Object]

← back to Sister Parish Onboarding

guard TK-11357 Fix D: sister-parish push_shopify.js — don't mint/stock a $0 variant as CONTINUE

3e2c3fc6afb961cc26c3dce68f5b660fddb207d6 · 2026-09-10 09:44:39 -0700 · Steve

Found by widening the writer enumeration to REST inventory_levels endpoints (my first sweep was
GraphQL-mutation-only, the same blind spot this lineage has hit before).

PRE-EXISTING GUARD STATE: none. buildProductPayload() prices every variant `v.dw_retail.toFixed(2)`
straight from the input feed with no price check, sets inventory_policy 'continue', and the caller
then stamps a flat 2026 on every created variant via /inventory_levels/set.json. A 0/absent
vendor_retail (dw_retail = vendor_retail / 0.85) therefore mints a $0 variant that is CONTINUE --
orderable at ANY quantity INCLUDING ZERO, so the qty->0 remedy cannot fix it.

FIX: inventory_policy comes from the guard ('deny' when the safe quantity is 0, caller's 'continue'
otherwise), and the stock loop stamps the guard's per-variant safe quantity computed from the price
Shopify actually landed -- skipping $0 variants with a logged refusal instead of stocking them.
Priced variants keep 'continue' + 2026 exactly as before (Steve's 2026-06-20 rule intact).

Shared guard vendored to scripts/lib/. Proven: 4/4 + 3/3 + P8 policy 2/2.
SOURCE-ONLY. Reversible: git revert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Files touched

Diff

commit 3e2c3fc6afb961cc26c3dce68f5b660fddb207d6
Author: Steve <steve@designerwallcoverings.com>
Date:   Thu Sep 10 09:44:39 2026 -0700

    guard TK-11357 Fix D: sister-parish push_shopify.js — don't mint/stock a $0 variant as CONTINUE
    
    Found by widening the writer enumeration to REST inventory_levels endpoints (my first sweep was
    GraphQL-mutation-only, the same blind spot this lineage has hit before).
    
    PRE-EXISTING GUARD STATE: none. buildProductPayload() prices every variant `v.dw_retail.toFixed(2)`
    straight from the input feed with no price check, sets inventory_policy 'continue', and the caller
    then stamps a flat 2026 on every created variant via /inventory_levels/set.json. A 0/absent
    vendor_retail (dw_retail = vendor_retail / 0.85) therefore mints a $0 variant that is CONTINUE --
    orderable at ANY quantity INCLUDING ZERO, so the qty->0 remedy cannot fix it.
    
    FIX: inventory_policy comes from the guard ('deny' when the safe quantity is 0, caller's 'continue'
    otherwise), and the stock loop stamps the guard's per-variant safe quantity computed from the price
    Shopify actually landed -- skipping $0 variants with a logged refusal instead of stocking them.
    Priced variants keep 'continue' + 2026 exactly as before (Steve's 2026-06-20 rule intact).
    
    Shared guard vendored to scripts/lib/. Proven: 4/4 + 3/3 + P8 policy 2/2.
    SOURCE-ONLY. Reversible: git revert.
    
    Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---
 scripts/lib/inventory-stamp-guard.mjs | 78 +++++++++++++++++++++++++++++++++++
 scripts/push_shopify.js               | 51 ++++++++++++++++++++---
 2 files changed, 124 insertions(+), 5 deletions(-)

diff --git a/scripts/lib/inventory-stamp-guard.mjs b/scripts/lib/inventory-stamp-guard.mjs
new file mode 100644
index 0000000..195b65f
--- /dev/null
+++ b/scripts/lib/inventory-stamp-guard.mjs
@@ -0,0 +1,78 @@
+// VENDORED COPY — canonical source: Designer-Wallcoverings/shopify/scripts/lib/inventory-stamp-guard.mjs
+// Vendored (not cross-repo-imported): this repo runs independently; a cross-repo relative import
+// would hard-crash if either tree moved. KEEP IN SYNC — the TK-11357 harness hashes every copy.
+// TK-10965 — Fix B (prevention): the inventory-stamp invariant, as a pure guard.
+//
+// ROOT CAUSE (see ../FINDINGS.md): importers stamp a positive "cap-free" stock
+// number (the year literal 2026) on the SELLABLE non-Sample variant of every
+// activated product — both in the product-create payload (`inventory_quantity: 2026`)
+// and on reconcile (`setInventory2026()`). When that sellable variant is ALSO
+// priced $0 (quote-only / contact-for-price lines like Phillipe Romano, Fentucci
+// Naturals), positive stock makes it `availableForSale` → checkout-orderable for $0.
+//
+// THE INVARIANT this module enforces (one place, both call sites):
+//   A sellable variant that is priced $0 OR belongs to a quote-only / price-
+//   suppressed line must NEVER receive positive inventory. It gets 0 → not orderable.
+//   (The $4.25 Sample variant is unaffected — it is not the sellable variant and is
+//    already qty=0/non-orderable by design.)
+//
+// PURE + dependency-free on purpose: no network, no env, no Shopify client, so it
+// unit-tests offline and drops into any importer runtime unchanged. $0 (local).
+
+// Tag family that means "this line has no public retail price" — a superset of the
+// single `quote-only` tag the standing canary keyed on (which is why Fentucci, tagged
+// `quotes`/`Needs-Price`, was the canary's 462-product blind spot).
+export const PRICE_SUPPRESSED_TAGS = new Set([
+  'quote-only', 'quote only', 'quote_only',
+  'quotes', 'contact-for-price', 'contact for price', 'needs-price', 'needs price',
+]);
+
+const norm = t => String(t).trim().toLowerCase();
+
+/**
+ * Is this product a quote-only / price-suppressed line?
+ * @param {{tags?: string[]|string, vendor?: string}} product
+ */
+export function isPriceSuppressed(product = {}) {
+  const tags = Array.isArray(product.tags)
+    ? product.tags
+    : String(product.tags || '').split(',');
+  if (tags.some(t => PRICE_SUPPRESSED_TAGS.has(norm(t)))) return true;
+  // Vendor fallback for untagged cohorts (Fentucci Naturals ships quote-only with
+  // zero quote-only tags). Extend as new price-on-request lines are onboarded.
+  return norm(product.vendor) === 'fentucci naturals';
+}
+
+/**
+ * A variant is the "sellable" one iff it is NOT the Sample variant.
+ * (Importers create exactly two variants: `Sample` @ $4.25 and the real unit @ price.)
+ * @param {{title?: string, option1?: string}} variant
+ */
+export function isSellableVariant(variant = {}) {
+  const label = variant.title ?? variant.option1 ?? '';
+  return !/sample/i.test(label);
+}
+
+/**
+ * Would giving this sellable variant positive stock make it a $0-orderable defect?
+ * True iff it's the sellable variant AND (price is 0 OR the line is price-suppressed).
+ * @param {object} variant  the variant about to be stamped
+ * @param {object} product  its parent (for tags/vendor)
+ */
+export function isZeroPriceOrderableRisk(variant = {}, product = {}) {
+  if (!isSellableVariant(variant)) return false;
+  const price = Number(variant.price);
+  return price === 0 || Number.isNaN(price) || isPriceSuppressed(product);
+}
+
+/**
+ * THE GUARD. Return the inventory quantity that is SAFE to stamp on this variant.
+ * Drop-in replacement for the literal `2026` at both call sites:
+ *   - create payload:  inventory_quantity: safeStampQuantity(variant, product)
+ *   - setInventory2026: quantity:        safeStampQuantity(variant, product)
+ * Returns `desired` (2026) for normal priced variants; 0 for the defect class.
+ * @returns {number} 0 for a zero-price-orderable risk, else `desired`
+ */
+export function safeStampQuantity(variant, product, desired = 2026) {
+  return isZeroPriceOrderableRisk(variant, product) ? 0 : desired;
+}
diff --git a/scripts/push_shopify.js b/scripts/push_shopify.js
index 0984d6a..db3f00f 100644
--- a/scripts/push_shopify.js
+++ b/scripts/push_shopify.js
@@ -60,6 +60,39 @@ async function shopify(method, pathSuffix, body) {
   return json;
 }
 
+const { safeStampQuantity } = require('./lib/inventory-stamp-guard.mjs');  // GUARD TK-11357 (shared guard)
+// ── GUARD TK-11357 BEGIN ─ do not edit without re-running the fixture proof ──────────
+// A $0 / quote-only sellable variant must NEVER receive positive stock: positive stock is what
+// flips availableForSale=true, making it checkout-orderable at $0 (lineage TK-10825 -> 10965 ->
+// 11140 -> 11299 -> 11301 -> 11357). $0 is the LIVE theme's deliberate quote-only SENTINEL
+// (snippets/product-form-content.liquid renders the "Contact Us" button iff variant.price == 0),
+// so the remedy is NEVER to write a placeholder price - it is "do not stock it".
+// Steve's 2026-06-20 "active products are never out of stock" rule is PRESERVED for PRICED goods:
+// a priced variant still gets `desired`. The quote-only tag/vendor decision is delegated to the
+// shared guard (inventory-stamp-guard.mjs); this adds one strictly-safer rule of its own -
+// price <= 0 / NaN is ALWAYS 0, even on a variant labelled "Sample" (a $0 "sample" is the same
+// $0-orderable defect). The real $4.25 memo sample is unaffected and keeps its existing quantity.
+// `inventoryItem?.id ?? null` because this writer decides on a CREATE PAYLOAD, where no
+// inventoryItem id exists yet; only the .quantity is consumed there.
+function safeQuantities(product, variants, locationId, desired) {
+  return (variants || []).map(v => ({
+    inventoryItemId: v.inventoryItem?.id ?? null,
+    locationId,
+    quantity: Number(v.price) > 0
+      ? safeStampQuantity({ title: v.title, price: v.price }, product, desired)
+      : 0,
+  }));
+}
+// inventoryPolicy CONTINUE (oversell) makes a variant orderable at ANY quantity INCLUDING ZERO.
+// So for the $0 class, "set the quantity to 0" is a NO-OP under CONTINUE and the entire
+// established remedy for this defect class silently fails. A variant the guard zeroes must
+// therefore ALSO be DENY, or the guard is decorative. Priced variants keep the caller's policy
+// exactly as-is, so oversell behaviour for real made-to-order goods is unchanged.
+function safePolicy(product, variant, desiredPolicy, desired) {
+  return safeQuantities(product, [variant], null, desired)[0].quantity > 0 ? desiredPolicy : 'deny';
+}
+// ── GUARD TK-11357 END ────────────────────────────────────────────
+
 function buildProductPayload(p) {
   // Option axes — collapse to (Pattern, Material, Size) like the source.
   const allOpts = p.variants.map(v => ({ o1: v.option1, o2: v.option2, o3: v.option3 }));
@@ -77,7 +110,10 @@ function buildProductPayload(p) {
       price: v.dw_retail.toFixed(2),
       compare_at_price: v.vendor_retail.toFixed(2),
       inventory_management: 'shopify',
-      inventory_policy: 'continue',
+      // GUARD TK-11357: dw_retail comes straight from the input feed with no price check, so a
+      // 0/absent vendor_retail minted a $0 variant with policy CONTINUE — orderable at ANY
+      // quantity INCLUDING ZERO, which defeats the qty->0 remedy for this defect class.
+      inventory_policy: safePolicy({ vendor: 'Sister Parish', tags: [] }, { title: v.option3 || v.option1, price: v.dw_retail }, 'continue', 2026),
       requires_shipping: true,
       taxable: true,
       weight: (v.grams || 0) / 1000,
@@ -134,6 +170,8 @@ async function setVariantInventory(variantId, qty) {
     await shopify('POST', '/inventory_levels/connect.json', { location_id: locationId, inventory_item_id: inventoryItemId });
   } catch (e) { /* already connected — ignore */ }
   // 4) Set qty
+  // GUARD TK-11357: never stamp positive stock without a price — callers must pass the guard's
+  // safe quantity (a $0 variant reaching this with a positive qty is the whole defect class).
   await shopify('POST', '/inventory_levels/set.json', {
     location_id: locationId, inventory_item_id: inventoryItemId, available: qty
   });
@@ -160,13 +198,16 @@ async function setVariantInventory(variantId, qty) {
       created.push({ sp_id: p.sp_product_id, sf_id: prod.id, handle: prod.handle, variants: prod.variants.length });
       console.log(`${stamp} ✓ ${p.title}  → SF #${prod.id}  (${prod.variants.length} variants, ${prod.images.length} images)`);
 
-      // Set inventory qty=2026 on every variant (per standing rule)
-      for (const v of prod.variants) {
+      // Set inventory qty=2026 on every PRICED variant (standing rule), per GUARD TK-11357:
+      // stock from the price Shopify actually landed, never a flat 2026 — a $0 variant gets 0.
+      for (const q of safeQuantities({ vendor: 'Sister Parish', tags: [] },
+             prod.variants.map(v => ({ title: v.option3 || v.option1, price: v.price, inventoryItem: { id: v.id } })), null, 2026)) {
+        if (q.quantity === 0) { console.log(`     · ⛔ variant ${q.inventoryItemId}: $0 — NOT stocked (TK-11357 guard)`); continue; }
         try {
-          await setVariantInventory(v.id, 2026);
+          await setVariantInventory(q.inventoryItemId, q.quantity);
           await sleep(RATE_DELAY_MS);
         } catch (invErr) {
-          console.log(`     · variant ${v.id} inventory set failed: ${invErr.message.slice(0,140)}`);
+          console.log(`     · variant ${q.inventoryItemId} inventory set failed: ${invErr.message.slice(0,140)}`);
         }
       }
     } catch (e) {

← 22f4fcc importer: map global.width metafield (Carnegie-class fix)  ·  back to Sister Parish Onboarding  ·  governance(TK-11370): make live-by-default Shopify writers d 4322d1d →