[object Object]

← back to Dwjs Bolt Single Roll Fix

DWJS peel&stick bolt→single-roll leak fix: staged gated executor + mapping + memo

607694a8eb880661f5b60fdc35a40f507cda883a · 2026-06-29 09:15:17 -0700 · steve

Files touched

Diff

commit 607694a8eb880661f5b60fdc35a40f507cda883a
Author: steve <steve@designerwallcoverings.com>
Date:   Mon Jun 29 09:15:17 2026 -0700

    DWJS peel&stick bolt→single-roll leak fix: staged gated executor + mapping + memo
---
 .gitignore               |   8 +++
 README.md                |  57 ++++++++++++++++
 apply-single-roll-fix.js | 166 +++++++++++++++++++++++++++++++++++++++++++++++
 mapping.csv              |  39 +++++++++++
 4 files changed, 270 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..d96bfe9
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,8 @@
+node_modules/
+.env*
+tmp/
+*.log
+.DS_Store
+dist/
+build/
+rollback.json
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..4c8b214
--- /dev/null
+++ b/README.md
@@ -0,0 +1,57 @@
+# dwjs-bolt-single-roll-fix
+
+Stops a confirmed **below-cost money leak** on the Jeffrey Stevens / Apartment
+Wallpaper peel & stick line (York "Magnolia Home Premium Peel + Stick",
+`PSW####RL`) on the live DW Shopify store (`designer-laboratory-sandbox`).
+
+## The leak (confirmed)
+- Order #32456 sold **DWJS-17621** as variant **"Price Per Bolt" @ $31.86**.
+- A **bolt = double roll = 2 rolls**. Vendor net cost = **$34.00 per double roll**
+  (York portal). York ships **only un-splittable $34 double rolls** (Steve, 2026-06-29).
+- $31.86 collected vs $34.00 cost shipped = **below cost on every unit**, before margin.
+
+## Cohort (38 active variants, all option `Size = "Price Per Bolt"`)
+| count | live price | reading |
+|------:|-----------:|---------|
+| 11 | $31.86 | single-roll price on a bolt unit → **leaking** |
+| 3  | $34.31 | ~single-roll price → leaking |
+| 14 | $65.00 | correct **bolt** price (≈ $34 / .65 / .85) |
+| 10 | $60.00 | ~bolt price |
+
+## The fix (Steve's decision: sell by single roll, normalize, never lose money)
+Because York ships an **un-splittable $34 double roll**, a true qty-1 single-roll
+sale at $31.86 would strand the 2nd roll and still lose money. The only
+loss-proof way to offer a ~$31.86 single-roll price is to **sell in pairs** —
+the same mechanism RL wallcovering already uses (min 2 / step 2). Per variant:
+
+1. **Relabel** option value `Price Per Bolt` → `Price Per Single Roll`.
+2. **Price** = `$31.86` (single-roll retail; ≥ $30.77 DW floor on $17/roll).
+3. **Order minimum** metafields on the product (the live theme renders these
+   onto the cart qty input):
+   - `global.v_prods_quantity_order_min` = `2`
+   - `global.v_prods_quantity_order_units` = `2`   (even qty only → whole bolts)
+   - `global.unit_of_measure` = `Priced Per Single Roll`
+
+### Result — never below cost, never a stranded roll
+Every order consumes a full $34 bolt: **2 × $31.86 = $63.72 revenue / $34 cost
+= ~$29.72 margin (~47%)**.
+- 14 leaking SKUs: effective bolt revenue $31.86 → **$63.72** (leak closed).
+- 24 bolt-priced SKUs: ~$63.72 ≈ current $60–65 (revenue ~unchanged), now
+  consistent single-roll UX.
+
+## Files
+- `mapping.csv` — current → proposed per variant (38 rows; 27 price changes).
+- `apply-single-roll-fix.js` — snapshot-first, **GATED** executor.
+  - Default = read-only: writes `rollback.json` (current option value + price +
+    order-min metafields) and prints the plan. Writes **nothing** to Shopify.
+  - `--execute --i-have-steve-go` = perform the live writes (rename + price +
+    metafields), snapshot-first, fail-closed on any userError.
+  - `--rollback rollback.json --execute --i-have-steve-go` = restore.
+
+## Gate
+This is a **customer-facing live Shopify write = Steve-gated**. Do not run
+`--execute` until Steve signs the pending-approval memo and says go.
+Open prerequisite to re-confirm: York truly cannot ship a separable single roll.
+
+## Cost
+$0 — local psql + Shopify Admin GraphQL (no per-call charge).
diff --git a/apply-single-roll-fix.js b/apply-single-roll-fix.js
new file mode 100644
index 0000000..009e30e
--- /dev/null
+++ b/apply-single-roll-fix.js
@@ -0,0 +1,166 @@
+#!/usr/bin/env node
+/*
+ * apply-single-roll-fix.js — stop the DWJS peel & stick below-cost leak.
+ *
+ * Per variant (38 rows in mapping.csv):
+ *   1. rename option value "Price Per Bolt" -> "Price Per Single Roll"
+ *   2. set price = $31.86 (single-roll retail)
+ *   3. set product order-min metafields: min=2, step=2, unit display
+ *      so every order consumes a full $34 double-roll bolt (no stranded roll).
+ *
+ * SAFETY (gated-work pattern):
+ *   - DEFAULT = read-only. Snapshots current state to rollback.json + prints the
+ *     plan. Writes NOTHING to Shopify.
+ *   - LIVE writes require BOTH --execute AND --i-have-steve-go.
+ *   - Snapshot-first: rollback.json is written before any mutation.
+ *   - Fail-closed: any GraphQL userError aborts the whole run.
+ *   - Rollback verb: --rollback rollback.json --execute --i-have-steve-go.
+ *
+ * Token: SHOPIFY_ADMIN_TOKEN (write_products) from the DW shopify/.env. Never logged.
+ * Cost: $0 (Admin GraphQL has no per-call charge).
+ */
+'use strict';
+const fs = require('fs');
+const path = require('path');
+
+const SHOP_ENV = path.join(process.env.HOME, 'Projects/Designer-Wallcoverings/shopify/.env');
+const API = '2024-10';
+const NEW_OPTION_VALUE = 'Price Per Single Roll';
+const SINGLE_PRICE = '31.86';
+const SINGLE_FLOOR = 30.77;          // cost $17/roll / .65 / .85 — never price below this
+const MIN_QTY = '2', STEP_QTY = '2'; // even qty only => whole bolts
+const UNIT_DISPLAY = 'Priced Per Single Roll';
+
+function env(k) {
+  try {
+    const line = fs.readFileSync(SHOP_ENV, 'utf8').split('\n').find(l => l.startsWith(k + '='));
+    return line ? line.slice(k.length + 1).trim() : (process.env[k] || null);
+  } catch { return process.env[k] || null; }
+}
+const TOKEN = env('SHOPIFY_ADMIN_TOKEN');
+const STORE = env('SHOPIFY_STORE_DOMAIN') || 'designer-laboratory-sandbox.myshopify.com';
+
+const args = process.argv.slice(2);
+const has = f => args.includes(f);
+const val = f => { const i = args.indexOf(f); return i >= 0 ? args[i + 1] : null; };
+const EXECUTE = has('--execute') && has('--i-have-steve-go');
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+
+async function gql(query, variables) {
+  const res = await fetch(`https://${STORE}/admin/api/${API}/graphql.json`, {
+    method: 'POST',
+    headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' },
+    body: JSON.stringify({ query, variables }),
+  });
+  const j = await res.json();
+  if (j.errors) throw new Error('GraphQL: ' + JSON.stringify(j.errors));
+  return j.data;
+}
+
+function readMapping() {
+  const csv = fs.readFileSync(path.join(__dirname, 'mapping.csv'), 'utf8').trim().split('\n');
+  const head = csv[0].split(',');
+  return csv.slice(1).map(line => {
+    const c = line.split(',');
+    return Object.fromEntries(head.map((h, i) => [h, c[i]]));
+  });
+}
+
+const PRODUCT_Q = `query($id:ID!){ product(id:$id){ id handle
+  options{ id name optionValues{ id name } }
+  variants(first:20){ edges{ node{ id sku price selectedOptions{name value} } } }
+  metafields(first:50){ edges{ node{ namespace key value type } } } } }`;
+
+async function snapshotProduct(pid) {
+  const d = await gql(PRODUCT_Q, { id: `gid://shopify/Product/${pid}` });
+  return d.product;
+}
+
+const OPTION_UPDATE = `mutation($productId:ID!,$option:OptionUpdateInput!,$vals:[OptionValueUpdateInput!]){
+  productOptionUpdate(productId:$productId, option:$option, optionValuesToUpdate:$vals){
+    userErrors{ field message } } }`;
+const VARIANTS_UPDATE = `mutation($productId:ID!,$variants:[ProductVariantsBulkInput!]!){
+  productVariantsBulkUpdate(productId:$productId, variants:$variants){ userErrors{ field message } } }`;
+const METAFIELDS_SET = `mutation($mf:[MetafieldsSetInput!]!){
+  metafieldsSet(metafields:$mf){ userErrors{ field message } } }`;
+
+function die(m){ console.error('REFUSING / ABORT: ' + m); process.exit(3); }
+function checkErrs(d, where){ const e = (d && Object.values(d)[0] || {}).userErrors || []; if(e.length) die(`${where}: ${JSON.stringify(e)}`); }
+
+async function main() {
+  if (!TOKEN) die('no SHOPIFY_ADMIN_TOKEN');
+  if (has('--execute') && !has('--i-have-steve-go'))
+    die('--execute requires --i-have-steve-go (explicit Steve approval token).');
+
+  const rows = readMapping();
+  console.log(`mode: ${EXECUTE ? 'LIVE WRITE' : 'read-only (snapshot + plan)'}  store=${STORE}  rows=${rows.length}`);
+
+  // ---- ROLLBACK verb ----
+  const rbFile = val('--rollback');
+  if (rbFile) {
+    const snap = JSON.parse(fs.readFileSync(rbFile, 'utf8'));
+    console.log(`rollback: restoring ${snap.length} products from ${rbFile}`);
+    if (!EXECUTE) return console.log('(read-only; pass --execute --i-have-steve-go to restore)');
+    for (const s of snap) {
+      checkErrs(await gql(OPTION_UPDATE, { productId:`gid://shopify/Product/${s.product_id}`,
+        option:{ id:s.option_id }, vals:[{ id:s.option_value_id, name:s.old_option_value }] }), 'rollback option');
+      checkErrs(await gql(VARIANTS_UPDATE, { productId:`gid://shopify/Product/${s.product_id}`,
+        variants:[{ id:`gid://shopify/ProductVariant/${s.variant_id}`, price:s.old_price }] }), 'rollback price');
+      await sleep(600);
+    }
+    return console.log('rollback complete.');
+  }
+
+  // ---- FORWARD: snapshot-first ----
+  const snapshot = [];
+  const plan = [];
+  for (const r of rows) {
+    if (Number(r.new_price) < SINGLE_FLOOR) die(`${r.dw_sku}: new_price ${r.new_price} < floor ${SINGLE_FLOOR}`);
+    const p = await snapshotProduct(r.product_id);
+    const sizeOpt = p.options.find(o => o.name === 'Size') || p.options[0];
+    const boltVal = sizeOpt.optionValues.find(v => v.name === 'Price Per Bolt');
+    const variant = p.variants.edges.map(e => e.node).find(v => v.sku === r.dw_sku);
+    const mfMin = p.metafields.edges.find(e => e.node.namespace==='global' && e.node.key==='v_prods_quantity_order_min');
+    const mfStep = p.metafields.edges.find(e => e.node.namespace==='global' && e.node.key==='v_prods_quantity_order_units');
+    snapshot.push({
+      dw_sku:r.dw_sku, product_id:r.product_id, handle:p.handle,
+      option_id:sizeOpt.id, option_value_id: boltVal ? boltVal.id : null,
+      old_option_value: boltVal ? boltVal.name : '(none-found)',
+      variant_id: variant ? variant.id.split('/').pop() : r.variant_id,
+      old_price: variant ? variant.price : null,
+      old_min: mfMin ? mfMin.node.value : null, old_step: mfStep ? mfStep.node.value : null,
+    });
+    plan.push(`${r.dw_sku}: "${boltVal?boltVal.name:'?'}"→"${NEW_OPTION_VALUE}"  $${variant?variant.price:'?'}→$${SINGLE_PRICE}  min/step→${MIN_QTY}/${STEP_QTY}`);
+    await sleep(250);
+  }
+  fs.writeFileSync(path.join(__dirname, 'rollback.json'), JSON.stringify(snapshot, null, 2));
+  console.log(`\nsnapshot written: rollback.json (${snapshot.length} products)`);
+  console.log('plan:\n  ' + plan.join('\n  '));
+
+  if (!EXECUTE) {
+    console.log('\nread-only. No Shopify writes performed.');
+    console.log('To apply (gated): node apply-single-roll-fix.js --execute --i-have-steve-go');
+    return;
+  }
+
+  // ---- LIVE writes (snapshot already on disk) ----
+  let n = 0;
+  for (const s of snapshot) {
+    if (!s.option_value_id) { console.warn(`skip ${s.dw_sku}: no "Price Per Bolt" value found`); continue; }
+    checkErrs(await gql(OPTION_UPDATE, { productId:`gid://shopify/Product/${s.product_id}`,
+      option:{ id:s.option_id }, vals:[{ id:s.option_value_id, name:NEW_OPTION_VALUE }] }), `${s.dw_sku} option`);
+    checkErrs(await gql(VARIANTS_UPDATE, { productId:`gid://shopify/Product/${s.product_id}`,
+      variants:[{ id:`gid://shopify/ProductVariant/${s.variant_id}`, price:SINGLE_PRICE }] }), `${s.dw_sku} price`);
+    checkErrs(await gql(METAFIELDS_SET, { mf:[
+      { ownerId:`gid://shopify/Product/${s.product_id}`, namespace:'global', key:'v_prods_quantity_order_min',   type:'single_line_text_field', value:MIN_QTY },
+      { ownerId:`gid://shopify/Product/${s.product_id}`, namespace:'global', key:'v_prods_quantity_order_units', type:'single_line_text_field', value:STEP_QTY },
+      { ownerId:`gid://shopify/Product/${s.product_id}`, namespace:'global', key:'unit_of_measure',              type:'single_line_text_field', value:UNIT_DISPLAY },
+    ]}), `${s.dw_sku} metafields`);
+    n++;
+    if (n % 25 === 0) { console.log(`  …${n} done, pausing 90s (DW bulk rule)`); await sleep(90_000); }
+    else await sleep(700);
+  }
+  console.log(`\nLIVE WRITE complete: ${n} products relabeled + repriced + min-2 enforced.`);
+  console.log('rollback: node apply-single-roll-fix.js --rollback rollback.json --execute --i-have-steve-go');
+}
+main().catch(e => die(e.message));
diff --git a/mapping.csv b/mapping.csv
new file mode 100644
index 0000000..f6f564d
--- /dev/null
+++ b/mapping.csv
@@ -0,0 +1,39 @@
+dw_sku,product_id,variant_id,handle,cur_option,cur_price,new_option_value,new_price,new_min_qty,new_step,price_changes

+DWJS-17579,6685836378163,43175150288947,dwjs-17579,Size=Price Per Bolt,60.00,Price Per Single Roll,31.86,2,2,YES

+DWJS-17581,6685836443699,43175150747699,dwjs-17581,Size=Price Per Bolt,31.86,Price Per Single Roll,31.86,2,2,no

+DWJS-17588,6685836771379,43175152877619,dwjs-17588,Size=Price Per Bolt,31.86,Price Per Single Roll,31.86,2,2,no

+DWJS-17618,6685837033523,43175154483251,dwjs-17618,Size=Price Per Bolt,31.86,Price Per Single Roll,31.86,2,2,no

+DWJS-17619,6685837066291,43175154909235,dwjs-17619,Size=Price Per Bolt,60.00,Price Per Single Roll,31.86,2,2,YES

+DWJS-17621,6685837230131,43175155302451,dwjs-17621,Size=Price Per Bolt,31.86,Price Per Single Roll,31.86,2,2,no

+DWJS-17627,6685837492275,43175157235763,dwjs-17627,Size=Price Per Bolt,60.00,Price Per Single Roll,31.86,2,2,YES

+DWJS-17629,6685837623347,43175157596211,dwjs-17629,Size=Price Per Bolt,31.86,Price Per Single Roll,31.86,2,2,no

+DWJS-17630,6685837688883,43175157989427,dwjs-17630,Size=Price Per Bolt,31.86,Price Per Single Roll,31.86,2,2,no

+DWJS-17633,6685837852723,43175158808627,dwjs-17633,Size=Price Per Bolt,31.86,Price Per Single Roll,31.86,2,2,no

+DWJS-17635,6685837918259,43175159201843,dwjs-17635,Size=Price Per Bolt,60.00,Price Per Single Roll,31.86,2,2,YES

+DWJS-17641,6685838180403,43175160676403,dwjs-17641,Size=Price Per Bolt,60.00,Price Per Single Roll,31.86,2,2,YES

+DWJS-17644,6685838377011,43175163330611,dwjs-17644,Size=Price Per Bolt,31.86,Price Per Single Roll,31.86,2,2,no

+DWJS-17721,6685838934067,43175167197235,dwjs-17721,Size=Price Per Bolt,34.31,Price Per Single Roll,31.86,2,2,YES

+DWJS-17726,6685839196211,43175168770099,dwjs-17726,Size=Price Per Bolt,65.00,Price Per Single Roll,31.86,2,2,YES

+DWJS-17729,6685839392819,43175169753139,dwjs-17729,Size=Price Per Bolt,65.00,Price Per Single Roll,31.86,2,2,YES

+DWJS-17731,6685839491123,43175170342963,dwjs-17731,Size=Price Per Bolt,65.00,Price Per Single Roll,31.86,2,2,YES

+DWJS-17734,6685839622195,43175171162163,dwjs-17734,Size=Price Per Bolt,65.00,Price Per Single Roll,31.86,2,2,YES

+DWJS-17735,6685839654963,43175171489843,dwjs-17735,Size=Price Per Bolt,65.00,Price Per Single Roll,31.86,2,2,YES

+DWJS-17736,6685839720499,43175171850291,dwjs-17736,Size=Price Per Bolt,65.00,Price Per Single Roll,31.86,2,2,YES

+DWJS-17739,6685839851571,43175172767795,dwjs-17739,Size=Price Per Bolt,65.00,Price Per Single Roll,31.86,2,2,YES

+DWJS-17743,6685840048179,43175174701107,dwjs-17743,Size=Price Per Bolt,65.00,Price Per Single Roll,31.86,2,2,YES

+DWJS-17756,6685840703539,43175180959795,dwjs-17756,Size=Price Per Bolt,34.31,Price Per Single Roll,31.86,2,2,YES

+DWJS-17760,6685840867379,43175182434355,dwjs-17760,Size=Price Per Bolt,31.86,Price Per Single Roll,31.86,2,2,no

+DWJS-17765,6685841096755,43175184007219,dwjs-17765,Size=Price Per Bolt,65.00,Price Per Single Roll,31.86,2,2,YES

+DWJS-17768,6685841260595,43175185350707,dwjs-17768,Size=Price Per Bolt,65.00,Price Per Single Roll,31.86,2,2,YES

+DWJS-17769,6685841326131,43175185940531,dwjs-17769,Size=Price Per Bolt,65.00,Price Per Single Roll,31.86,2,2,YES

+DWJS-17770,6685841358899,43175186300979,dwjs-17770,Size=Price Per Bolt,65.00,Price Per Single Roll,31.86,2,2,YES

+DWJS-17771,6685841391667,43175186726963,dwjs-17771,Size=Price Per Bolt,65.00,Price Per Single Roll,31.86,2,2,YES

+DWJS-17772,6685841424435,43175187251251,dwjs-17772,Size=Price Per Bolt,60.00,Price Per Single Roll,31.86,2,2,YES

+DWJS-17773,6685841522739,43175187578931,dwjs-17773,Size=Price Per Bolt,60.00,Price Per Single Roll,31.86,2,2,YES

+DWJS-17774,6685841555507,43175187873843,dwjs-17774,Size=Price Per Bolt,60.00,Price Per Single Roll,31.86,2,2,YES

+DWJS-17775,6685841588275,43175188201523,dwjs-17775,Size=Price Per Bolt,60.00,Price Per Single Roll,31.86,2,2,YES

+DWJS-17776,6685841653811,43175188561971,dwjs-17776,Size=Price Per Bolt,65.00,Price Per Single Roll,31.86,2,2,YES

+DWJS-17777,6685841752115,43175188889651,dwjs-17777,Size=Price Per Bolt,60.00,Price Per Single Roll,31.86,2,2,YES

+DWJS-17779,6685841850419,43175189479475,dwjs-17779,Size=Price Per Bolt,31.86,Price Per Single Roll,31.86,2,2,no

+DWJS-17780,6685841915955,43175189741619,dwjs-17780,Size=Price Per Bolt,31.86,Price Per Single Roll,31.86,2,2,no

+DWJS-17781,6685841948723,43175190102067,dwjs-17781,Size=Price Per Bolt,34.31,Price Per Single Roll,31.86,2,2,YES

(oldest)  ·  back to Dwjs Bolt Single Roll Fix  ·  Add York/Brewster bolt audit: only Apartment Wallpaper(14) + 2e35a0e →