← back to Designer Wallcoverings
fix(weight-guard): use real product_type on import so fabrics/murals aren't stamped 3.0 lb
0c4f57716986a99c42c89ef7757647c3ef0778b6 · 2026-09-14 15:07:00 -0700 · Steve
createOrGetProduct passed a hardcoded 'Wallcovering' into addSampleVariant, so the
TK-11539 weight guard's per-type table (Fabric 1.0, Mural 4.0, …) was never used and
every import shipped at the 3.0 lb wallcovering default — 3x over-weight freight on
fabric lines (Carnegie) routed through the shared engine. Now threads
dto.specs.product_type (falling back to 'Wallcovering' for typeless imports, no change
to existing wallcovering behavior). Verified: tsc clean, 28 Jest tests pass.
(Commit also carries the surrounding TK-11403/TK-11539 write-path wiring in shopify.ts —
price-integrity gate + activate-then-price ordering — as it shares the same hunks.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ct1qVRFZ6mxuizbSCLDe1n
Files touched
M DW-Programming/ImportNewSkufromURL/lib/shopify.ts
Diff
commit 0c4f57716986a99c42c89ef7757647c3ef0778b6
Author: Steve <steve@designerwallcoverings.com>
Date: Mon Sep 14 15:07:00 2026 -0700
fix(weight-guard): use real product_type on import so fabrics/murals aren't stamped 3.0 lb
createOrGetProduct passed a hardcoded 'Wallcovering' into addSampleVariant, so the
TK-11539 weight guard's per-type table (Fabric 1.0, Mural 4.0, …) was never used and
every import shipped at the 3.0 lb wallcovering default — 3x over-weight freight on
fabric lines (Carnegie) routed through the shared engine. Now threads
dto.specs.product_type (falling back to 'Wallcovering' for typeless imports, no change
to existing wallcovering behavior). Verified: tsc clean, 28 Jest tests pass.
(Commit also carries the surrounding TK-11403/TK-11539 write-path wiring in shopify.ts —
price-integrity gate + activate-then-price ordering — as it shares the same hunks.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ct1qVRFZ6mxuizbSCLDe1n
---
DW-Programming/ImportNewSkufromURL/lib/shopify.ts | 257 ++++++++++++++++++++--
1 file changed, 242 insertions(+), 15 deletions(-)
diff --git a/DW-Programming/ImportNewSkufromURL/lib/shopify.ts b/DW-Programming/ImportNewSkufromURL/lib/shopify.ts
index 9e63a4c2..c04f6b09 100644
--- a/DW-Programming/ImportNewSkufromURL/lib/shopify.ts
+++ b/DW-Programming/ImportNewSkufromURL/lib/shopify.ts
@@ -5,6 +5,13 @@ import { ProductDTO, ShopifyProduct, ShopifyProductResult } from './types';
import { getNextSKU } from './sku-counter';
import { addToHistory } from './sku-history';
import { assignDwSku, registerShopifyInfo } from './dw-sku-registry';
+// Price-integrity gate (TK-11403) — blocks a bad price before it is written.
+import { assertPriceIntegrity, resolveSampleFloor } from './price-integrity-gate';
+import { resolveNetCost } from './price-integrity-cost';
+import { recordOutcome } from './price-integrity-record';
+// Weight guard (TK-11539) — inject a positive inventoryItem.measurement.weight at variant
+// create so no imported product ships zero-weight (TK-11414 recurrence prevention).
+import { inventoryItemWithWeight } from './weight-guard';
// ---- Brewster (& imprints) per-roll price list ----
// The Brewster scraper returns NO price, so new SKUs would import at the $4.25 sample
@@ -254,7 +261,15 @@ async function findExistingProduct(fingerprint: string): Promise<ShopifyProduct
}
// Create product with metafield and sample variant
-async function createProductWithMetafield(dto: ProductDTO, priceUnit: string, unitLabel: string): Promise<ShopifyProduct | null> {
+// Fix (2026-09-14, activate-then-price ordering bug): createProductWithMetafield
+// used to decide ACTIVE from a PRE-gate price guess and Shopify would auto-create the
+// sellable variant at $0 — the real price is only written later in addSampleVariant(),
+// AFTER the price-integrity gate. A gate BLOCK / infra error / failed price write left
+// that ACTIVE, all-channels-published $0 product live (TK-11357 recurrence). Now this
+// function ALWAYS creates DRAFT and returns the baseline eligibility flags
+// (hasImage/hasWidth/hasPrice) so createOrGetProduct() can AND them with the real
+// post-gate outcome before ever promoting to ACTIVE + publishing.
+async function createProductWithMetafield(dto: ProductDTO, priceUnit: string, unitLabel: string): Promise<{ product: ShopifyProduct; hasImage: boolean; hasWidth: boolean; hasPrice: boolean } | null> {
const mutation = `
mutation ProductCreate($input: ProductInput!) {
productCreate(input: $input) {
@@ -808,12 +823,16 @@ async function createProductWithMetafield(dto: ProductDTO, priceUnit: string, un
// the product DRAFT and tag Needs-Price rather than publishing a $0/sample-priced roll.
const numericUnitPrice = parseFloat(String(dto.price || '').replace(/[^0-9.]/g, ''));
const hasPrice = Number.isFinite(numericUnitPrice) && numericUnitPrice > 4.25;
- const productStatus = hasImage && hasWidth && hasPrice ? 'ACTIVE' : 'DRAFT';
+ // Fix (2026-09-14): NEVER decide ACTIVE here from this pre-gate guess anymore — see
+ // the comment on the function signature above. Always create DRAFT; hasImage/hasWidth/
+ // hasPrice still drive the Needs-* tags below AND are returned to the caller as the
+ // baseline eligibility gate it ANDs with the real price-integrity gate outcome.
+ const productStatus = 'DRAFT';
const gateTags: string[] = [];
if (!hasImage) gateTags.push('Needs-Image');
if (!hasWidth) gateTags.push('Needs-Width');
if (!hasPrice) gateTags.push('Needs-Price');
- if (productStatus === 'DRAFT') {
+ if (!(hasImage && hasWidth && hasPrice)) {
console.warn(
`📝 Creating as DRAFT (gate): hasImage=${hasImage} hasWidth=${hasWidth} hasPrice=${hasPrice} — needs all three to go ACTIVE`
);
@@ -922,14 +941,24 @@ async function createProductWithMetafield(dto: ProductDTO, priceUnit: string, un
handle: product.handle,
}));
- return product;
+ return { product, hasImage, hasWidth, hasPrice };
+}
+
+// Result of the sell-price write — used by createOrGetProduct() to decide whether
+// the product is allowed to be promoted to ACTIVE + published (2026-09-14 fix).
+// priced:true ONLY when the sellable variant's productVariantsBulkUpdate price write
+// actually succeeded (no userErrors); gateBlocked:true when the price-integrity gate
+// blocked the write outright.
+export interface AddSampleVariantResult {
+ priced: boolean;
+ gateBlocked: boolean;
}
// Price BOTH the per-unit (Per Roll / Per Yard) variant AND the Sample variant that
// were auto-created from the two Size option values. The per-unit variant carries the
// real scraped vendor price (standing rule: every new SKU must have a price per unit);
// the Sample variant is fixed at $4.25 (standing rule: every product has a Sample).
-async function addSampleVariant(productId: string, sku: string, price: string, unitLabel: string, unitKind: string): Promise<void> {
+async function addSampleVariant(productId: string, sku: string, price: string, unitLabel: string, unitKind: string, vendor: string = '', productType: string = 'Wallcovering'): Promise<AddSampleVariantResult> {
// First, get the product's variants (Shopify auto-created them from productOptions)
const getVariantsQuery = `
query getProductVariants($id: ID!) {
@@ -981,7 +1010,66 @@ async function addSampleVariant(productId: string, sku: string, price: string, u
const numericUnitPrice = numericPrice || '0.00';
const unitVariant = findBySize(unitLabel) || existingVariants[0];
+ // ---- PRICE-INTEGRITY GATE (TK-11403) — BEFORE the two productVariantsBulk* writes ----
+ // Blocks the recurring bugs (A sample/default-price leak, B markup/absolute-floor,
+ // C $0-orderable) at the write choke point. FAIL-SAFE: resolveNetCost + the gate
+ // never throw out of the import path; a null cost is a non-blocking WARN
+ // (A-with-teeth) so ~97 no-cost vendors still import; only a real VIOLATION blocks.
+ let gateBlocked = false;
+ // TK-11403: resolve the per-vendor DECLARED sample price (default $4.25). This same
+ // value is both the Class-A gate floor AND the price actually written for the sample
+ // variant below, so a vendor with a higher declared sample (e.g. DW Bespoke $12) both
+ // gets its sample written at $12 and passes the gate instead of blocking on a $4.25 leak.
+ const sampleFloor = resolveSampleFloor(vendor);
+ try {
+ const netCost = await resolveNetCost(vendor, sku); // fail-safe → null on any error
+ const gate = assertPriceIntegrity({
+ dwSku: sku,
+ netCost,
+ sampleFloor,
+ variants: [
+ {
+ role: 'sellable',
+ price: Number(numericUnitPrice),
+ orderable: true, // CONTINUE + setInventoryQuantity(...,2025) below make it orderable
+ sku: `${sku}-${unitKind}`,
+ priceSource: numericPrice ? 'scraped' : 'defaulted',
+ },
+ { role: 'sample', price: sampleFloor, orderable: false, sku: `${sku}-Sample` },
+ ],
+ });
+ if (gate.warnings.length) {
+ recordOutcome({ vendor, dwSku: sku, outcome: 'warn', codes: gate.warnings.map(w => w.code) });
+ }
+ if (!gate.ok) {
+ console.error(JSON.stringify({ event: 'price_integrity_block', dwSku: sku, vendor, violations: gate.violations }));
+ recordOutcome({ vendor, dwSku: sku, outcome: 'block', codes: gate.violations.map(v => v.code) });
+ gateBlocked = true;
+ }
+ } catch (gateErr) {
+ // Gate INFRA error must NEVER block an import (A-with-teeth). Log + proceed.
+ console.error(JSON.stringify({
+ event: 'price_integrity_gate_error',
+ dwSku: sku,
+ vendor,
+ error: gateErr instanceof Error ? gateErr.message : String(gateErr),
+ }));
+ // (review 2026-09-14) Even on a gate INFRA error, keep the pure $0-orderable
+ // check FAIL-CLOSED. The sellable below is written CONTINUE + stocked (orderable),
+ // so a non-priced ($0) sellable is the exact recurring defect (TK-10825/10965/
+ // 11140/11301/11357). This check cannot throw, so enforce it here rather than
+ // lumping it with the cost-resolution risk that A-with-teeth intentionally waives.
+ if (!(Number(numericUnitPrice) > 0)) {
+ gateBlocked = true;
+ }
+ }
+ // (2026-09-14 fix) do NOT write the bad price — publish blocked. Return gateBlocked
+ // so createOrGetProduct() force-keeps the product DRAFT and skips publish, instead
+ // of relying on the caller to notice a swallowed void return.
+ if (gateBlocked) return { priced: false, gateBlocked: true };
+
// 1) Update the per-unit variant
+ let priced = false;
if (unitVariant) {
const upd = await shopifyGraphQL<{
productVariantsBulkUpdate: {
@@ -995,10 +1083,18 @@ async function addSampleVariant(productId: string, sku: string, price: string, u
userErrors { field message }
}
}`,
- { productId, variants: [{ id: unitVariant.node.id, price: numericUnitPrice, inventoryPolicy: 'CONTINUE', inventoryItem: { sku: `${sku}-${unitKind}`, tracked: true } }] }
+ // TK-11539: inventoryItemWithWeight() adds a positive measurement.weight (POUNDS)
+ // so the sellable variant is never created zero-weight.
+ { productId, variants: [{ id: unitVariant.node.id, price: numericUnitPrice, inventoryPolicy: 'CONTINUE', inventoryItem: inventoryItemWithWeight({ sku: `${sku}-${unitKind}`, tracked: true }, { role: 'sellable', productType }) }] }
);
const uerr = upd.productVariantsBulkUpdate.userErrors;
- if (uerr?.length) console.error(JSON.stringify({ event: 'shopify_unit_variant_error', productId, errors: uerr }));
+ if (uerr?.length) {
+ console.error(JSON.stringify({ event: 'shopify_unit_variant_error', productId, errors: uerr }));
+ } else {
+ // (2026-09-14 fix) priced:true ONLY on a confirmed, error-free price write —
+ // this is what createOrGetProduct() gates ACTIVE + publish on.
+ priced = true;
+ }
const iid = upd.productVariantsBulkUpdate.productVariants?.[0]?.inventoryItem?.id;
if (iid) await setInventoryQuantity(iid, 2025);
}
@@ -1017,7 +1113,10 @@ async function addSampleVariant(productId: string, sku: string, price: string, u
userErrors { field message }
}
}`,
- { productId, variants: [{ price: '4.25', optionValues: [{ name: 'Sample', optionName: 'Size' }], inventoryItem: { sku: `${sku}-Sample`, tracked: false } }] }
+ // TK-11539: sample variant also gets a positive measurement.weight (0.25 lb).
+ // TK-11403: write the per-vendor declared sample price (String(sampleFloor)); $4.25 for
+ // all non-declared vendors, higher for vendors in VENDOR_DECLARED_SAMPLE_PRICE.
+ { productId, variants: [{ price: String(sampleFloor), optionValues: [{ name: 'Sample', optionName: 'Size' }], inventoryItem: inventoryItemWithWeight({ sku: `${sku}-Sample`, tracked: false }, { role: 'sample', productType }) }] }
);
const cerr = cre.productVariantsBulkCreate.userErrors;
if (cerr?.length && !JSON.stringify(cerr).toLowerCase().includes('already exists')) {
@@ -1032,6 +1131,7 @@ async function addSampleVariant(productId: string, sku: string, price: string, u
unitLabel,
unitPrice: numericUnitPrice,
}));
+ return { priced, gateBlocked: false };
} catch (error) {
console.error(JSON.stringify({
timestamp: new Date().toISOString(),
@@ -1039,7 +1139,11 @@ async function addSampleVariant(productId: string, sku: string, price: string, u
productId,
error: error instanceof Error ? error.message : String(error),
}));
- // Don't throw - variant pricing is best-effort
+ // Don't throw - variant pricing is best-effort, but DO signal failure so the
+ // caller does not promote an unpriced/unwritten product to ACTIVE + published
+ // (2026-09-14 fix — this swallowed exception used to leave the caller with no
+ // way to know the price write never landed).
+ return { priced: false, gateBlocked: false };
}
}
@@ -1293,6 +1397,80 @@ async function publishToAllChannels(productId: string): Promise<void> {
}
}
+// (2026-09-14 fix) Promote a DRAFT product to ACTIVE. Used ONLY after
+// createOrGetProduct() confirms the eligibility gate (hasImage/hasWidth/hasPrice)
+// AND the real price-integrity gate/price write both succeeded — never at
+// productCreate time off a pre-gate guess.
+async function setProductStatus(productId: string, status: 'ACTIVE' | 'DRAFT'): Promise<boolean> {
+ try {
+ const data = await shopifyGraphQL<{
+ productUpdate: {
+ product: { id: string; status: string } | null;
+ userErrors: Array<{ field: string[]; message: string }>;
+ };
+ }>(
+ `mutation productUpdateStatus($input: ProductInput!) {
+ productUpdate(input: $input) {
+ product { id status }
+ userErrors { field message }
+ }
+ }`,
+ { input: { id: productId, status } }
+ );
+ const uerr = data.productUpdate.userErrors;
+ if (uerr?.length) {
+ console.error(JSON.stringify({ event: 'shopify_status_update_error', productId, status, errors: uerr }));
+ return false;
+ }
+ console.log(JSON.stringify({ event: 'shopify_status_update_success', productId, status }));
+ return true;
+ } catch (error) {
+ console.error(JSON.stringify({
+ event: 'shopify_status_update_error',
+ productId,
+ status,
+ error: error instanceof Error ? error.message : String(error),
+ }));
+ return false;
+ }
+}
+
+// (2026-09-14 fix) Add tag(s) to an existing product WITHOUT clobbering its current
+// tag list (tagsAdd is additive, unlike productUpdate's tags field which replaces).
+// Used to mark a gate-blocked / unpriced product 'Needs-Price-Review' after create.
+async function addProductTags(productId: string, tags: string[]): Promise<void> {
+ if (!tags.length) return;
+ try {
+ const data = await shopifyGraphQL<{
+ tagsAdd: {
+ node: { id: string } | null;
+ userErrors: Array<{ field: string[]; message: string }>;
+ };
+ }>(
+ `mutation tagsAdd($id: ID!, $tags: [String!]!) {
+ tagsAdd(id: $id, tags: $tags) {
+ node { id }
+ userErrors { field message }
+ }
+ }`,
+ { id: productId, tags }
+ );
+ const uerr = data.tagsAdd.userErrors;
+ if (uerr?.length) {
+ console.error(JSON.stringify({ event: 'shopify_tags_add_error', productId, tags, errors: uerr }));
+ }
+ } catch (error) {
+ console.error(JSON.stringify({
+ event: 'shopify_tags_add_error',
+ productId,
+ tags,
+ error: error instanceof Error ? error.message : String(error),
+ }));
+ // Don't throw - tagging is best-effort, it must never block the DRAFT-keeping
+ // decision it's annotating.
+ }
+}
+
// Sanitize product title to use as filename
function sanitizeTitleForFilename(title: string): string {
return title
@@ -1770,13 +1948,19 @@ export async function createOrGetProduct(dto: ProductDTO): Promise<ShopifyProduc
const unitLabel = unitVariantLabel(dto.specs, priceUnit);
const unitKind = unitKindOf(priceUnit);
- // Always create a new product - do not check for existing
- const product = await createProductWithMetafield(dto, priceUnit, unitLabel);
+ // Always create a new product - do not check for existing. (2026-09-14 fix) the
+ // product returned here is ALWAYS DRAFT now — createProductWithMetafield() no
+ // longer decides ACTIVE from a pre-gate price guess. eligibleForActive carries the
+ // baseline hasImage/hasWidth/hasPrice check; it's ANDed with the real price-integrity
+ // gate + price-write outcome (below) before this product is ever promoted.
+ const created = await createProductWithMetafield(dto, priceUnit, unitLabel);
// If product creation returned null (validation failed), throw error to skip
- if (!product) {
+ if (!created) {
throw new Error('Product validation failed - title became empty after exclusion rules');
}
+ const { product, hasImage, hasWidth, hasPrice } = created;
+ const eligibleForActive = hasImage && hasWidth && hasPrice;
console.log(JSON.stringify({
timestamp: new Date().toISOString(),
@@ -1806,7 +1990,14 @@ export async function createOrGetProduct(dto: ProductDTO): Promise<ShopifyProduc
priceUnit,
}));
- await addSampleVariant(product.id, sku, price, unitLabel, unitKind);
+ // TK-11539: pass the REAL product type through for the create-side weight default so the
+ // weight-guard's per-type table (Fabric 1.0 lb, Mural 4.0 lb, …) is actually used instead
+ // of stamping every import at the Wallcovering default (3.0 lb). The DTO carries the type
+ // at dto.specs.product_type ("e.g. Wallcovering, Fabric" — see ProductSpecsSchema); fall
+ // back to 'Wallcovering' (the DW house default) only when it is absent, preserving prior
+ // behavior for typeless imports. Carnegie and other fabric lines route through this engine.
+ const resolvedProductType = dto.specs?.product_type || 'Wallcovering';
+ const variantResult = await addSampleVariant(product.id, sku, price, unitLabel, unitKind, vendorName, resolvedProductType);
// Link the DW SKU to its Shopify product in the registry (audit + future dedup).
if (assignment) {
@@ -1816,8 +2007,44 @@ export async function createOrGetProduct(dto: ProductDTO): Promise<ShopifyProduc
// Add to SKU history
await addToHistory(sku, dto.title);
- // Publish to all sales channels
- await publishToAllChannels(product.id);
+ // (2026-09-14 fix — activate-then-price ordering bug) Promote to ACTIVE + publish
+ // ONLY when BOTH: (1) the baseline eligibility gate passed (hasImage && hasWidth &&
+ // hasPrice, computed at create time) AND (2) the real price-integrity gate passed AND
+ // the real sellable-variant price write actually succeeded (variantResult.priced,
+ // !variantResult.gateBlocked). A gate BLOCK, gate infra error, or failed price write
+ // now leaves the product DRAFT + tagged, never ACTIVE + all-channels-published at $0
+ // (TK-11357 recurrence this fix closes).
+ const priceWriteOk = variantResult.priced && !variantResult.gateBlocked;
+ if (eligibleForActive && priceWriteOk) {
+ const promoted = await setProductStatus(product.id, 'ACTIVE');
+ if (promoted) {
+ // Publish to all sales channels — only reached on a confirmed-priced, gate-passed,
+ // eligible product.
+ await publishToAllChannels(product.id);
+ } else {
+ // Status update itself failed — do NOT publish a product Shopify still has as
+ // DRAFT server-side; tag for review instead.
+ await addProductTags(product.id, ['Needs-Price-Review']);
+ console.warn(JSON.stringify({
+ event: 'product_kept_draft',
+ productId: product.id,
+ dwSku: sku,
+ reason: 'status_update_failed',
+ }));
+ }
+ } else {
+ if (variantResult.gateBlocked || !variantResult.priced) {
+ await addProductTags(product.id, ['Needs-Price-Review']);
+ }
+ console.warn(JSON.stringify({
+ event: 'product_kept_draft',
+ productId: product.id,
+ dwSku: sku,
+ eligibleForActive,
+ gateBlocked: variantResult.gateBlocked,
+ priced: variantResult.priced,
+ }));
+ }
// Add images to the product
if (dto.images && dto.images.length > 0) {
← c1d89e49 fix(versa-20oz): make dwc-sync rollback a true inverse (dele
·
back to Designer Wallcoverings
·
auto-data-snapshot: 2026-09-14T15:08:48 (3 data files) — sho ac9e69dc →