[object Object]

← back to CelebritySignatures

wear/POD: wire Printful sender + go-live path (gated OFF) — TK-10286

0fa8dbf50ca483f1984a524336a084315e619656 · 2026-08-10 15:14:47 -0700 · Steve Abrams

- submit-pod-draft.mjs: Printful /orders payload, triple-gated (--apply + PRINTFUL_API_KEY + WEAR_SALES_LIVE=1), confirm=false drafts, fail-loud on unmapped variant / missing shipping addr; dry-run default
- fetch-printful-catalog.mjs (new): read-only catalog->variant_id mapper
- wear-templates.json: per-garment printful{} block (empty by design)
- server.js: Stripe shipping_address_collection on /wear-checkout + carry address into order+POD draft (still behind WEAR_SALES_LIVE coming-soon gate; no visitor reaches checkout)
- verified node --check clean, dry-run submits nothing, unmapped garment skipped
- go-live memo -> pending-approval (real flip stays Steve-gated: key+live Stripe+WEAR_SALES_LIVE)

Files touched

Diff

commit 0fa8dbf50ca483f1984a524336a084315e619656
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Aug 10 15:14:47 2026 -0700

    wear/POD: wire Printful sender + go-live path (gated OFF) — TK-10286
    
    - submit-pod-draft.mjs: Printful /orders payload, triple-gated (--apply + PRINTFUL_API_KEY + WEAR_SALES_LIVE=1), confirm=false drafts, fail-loud on unmapped variant / missing shipping addr; dry-run default
    - fetch-printful-catalog.mjs (new): read-only catalog->variant_id mapper
    - wear-templates.json: per-garment printful{} block (empty by design)
    - server.js: Stripe shipping_address_collection on /wear-checkout + carry address into order+POD draft (still behind WEAR_SALES_LIVE coming-soon gate; no visitor reaches checkout)
    - verified node --check clean, dry-run submits nothing, unmapped garment skipped
    - go-live memo -> pending-approval (real flip stays Steve-gated: key+live Stripe+WEAR_SALES_LIVE)
---
 data/wear-templates.json           |  18 +++++-
 scripts/fetch-printful-catalog.mjs |  57 +++++++++++++++++++
 scripts/submit-pod-draft.mjs       | 113 +++++++++++++++++++++++++++----------
 server.js                          |  17 ++++--
 4 files changed, 168 insertions(+), 37 deletions(-)

diff --git a/data/wear-templates.json b/data/wear-templates.json
index 55ae349..ae1053d 100644
--- a/data/wear-templates.json
+++ b/data/wear-templates.json
@@ -17,7 +17,14 @@
         { "id": "heather", "label": "Heather Gray", "hex": "#d8d5cd", "ink": "#1a1a1a" },
         { "id": "sand", "label": "Sand", "hex": "#e7ddca", "ink": "#1a1a1a" },
         { "id": "sky", "label": "Sky", "hex": "#dbe6ef", "ink": "#16324a" }
-      ]
+      ],
+      "printful": {
+        "_note": "Printful mapping (fill after PRINTFUL_API_KEY lands — run scripts/fetch-printful-catalog.mjs). productId = Printful catalog product; variants maps our 'color/size' key -> Printful catalog variant_id (each color+size is a distinct Printful variant). placementType is the Printful print-file placement. Left EMPTY on purpose so scripts/submit-pod-draft.mjs FAILS LOUD (never submits an unmapped garment).",
+        "suggestedProduct": "Bella+Canvas 3001 Unisex Jersey Short-Sleeve Tee",
+        "productId": null,
+        "placementType": "front",
+        "variants": {}
+      }
     },
     {
       "id": "polo",
@@ -36,7 +43,14 @@
         { "id": "heather", "label": "Heather Gray", "hex": "#d8d5cd", "ink": "#1a1a1a" },
         { "id": "sand", "label": "Sand", "hex": "#e7ddca", "ink": "#1a1a1a" },
         { "id": "sky", "label": "Sky", "hex": "#dbe6ef", "ink": "#16324a" }
-      ]
+      ],
+      "printful": {
+        "_note": "Printful mapping (fill after PRINTFUL_API_KEY lands — run scripts/fetch-printful-catalog.mjs). productId = Printful catalog product; variants maps our 'color/size' key -> Printful catalog variant_id. Left EMPTY on purpose so scripts/submit-pod-draft.mjs FAILS LOUD (never submits an unmapped garment).",
+        "suggestedProduct": "Bella+Canvas 3001 Unisex Jersey Short-Sleeve Tee (or a polo equiv, e.g. Cotton Heritage / Adidas)",
+        "productId": null,
+        "placementType": "front",
+        "variants": {}
+      }
     }
   ]
 }
diff --git a/scripts/fetch-printful-catalog.mjs b/scripts/fetch-printful-catalog.mjs
new file mode 100644
index 0000000..04b487a
--- /dev/null
+++ b/scripts/fetch-printful-catalog.mjs
@@ -0,0 +1,57 @@
+#!/usr/bin/env node
+// READ-ONLY Printful catalog helper for /wear go-live wiring (TK-10286).
+//
+// Once PRINTFUL_API_KEY is set, this fetches a Printful catalog product's variants
+// and prints a suggested `variants` map ({ "color/size": variant_id }) to paste into
+// data/wear-templates.json for each garment. It ONLY issues GET requests to the
+// Printful catalog — it never creates, confirms, or touches an order. Safe to run
+// any time the key is present; a no-op (prints guidance) when it is not.
+//
+// Usage:
+//   node scripts/fetch-printful-catalog.mjs                 # list candidate products
+//   node scripts/fetch-printful-catalog.mjs <productId>     # dump variants for a product
+import { fileURLToPath } from 'node:url';
+import { join } from 'node:path';
+import { readFileSync } from 'node:fs';
+
+const ROOT = fileURLToPath(new URL('..', import.meta.url));
+function envVal(name) {
+  if (process.env[name]) return process.env[name];
+  for (const p of [join(ROOT, '.env'), join(ROOT, '..', 'secrets-manager', '.env')]) {
+    try { const m = readFileSync(p, 'utf8').match(new RegExp('^' + name + '=(.+)$', 'm')); if (m) return m[1].trim(); } catch {}
+  }
+  return null;
+}
+
+async function pf(key, path) {
+  const r = await fetch('https://api.printful.com' + path, { headers: { Authorization: `Bearer ${key}` } });
+  const j = await r.json().catch(() => ({}));
+  if (!r.ok) throw new Error(`Printful ${r.status}: ${j.error?.message || 'error'} on ${path}`);
+  return j.result;
+}
+
+async function main() {
+  const key = envVal('PRINTFUL_API_KEY');
+  if (!key) {
+    console.log('PRINTFUL_API_KEY not set — paste it via the secrets skill first.');
+    console.log('This script is read-only (GET /products only) and will list candidate garments once the key is present.');
+    return;
+  }
+  const productId = process.argv[2];
+  if (!productId) {
+    const products = await pf(key, '/products');
+    const tees = products.filter(p => /t-?shirt|tee|polo/i.test(p.type_name || p.title || ''));
+    console.log(`Catalog products matching tee/polo (${tees.length}):`);
+    for (const p of tees.slice(0, 40)) console.log(`  id=${p.id}  ${p.title}  (${p.type_name})`);
+    console.log(`\nNext: node scripts/fetch-printful-catalog.mjs <id>  to dump variants for the chosen product.`);
+    return;
+  }
+  const prod = await pf(key, `/products/${productId}`);
+  console.log(`Product ${productId}: ${prod.product?.title}`);
+  console.log(`Map our color/size -> Printful variant_id. Suggested variants block (edit color labels to match our ids):`);
+  const map = {};
+  for (const v of prod.variants || []) map[`${v.color}/${v.size}`] = v.id;
+  console.log(JSON.stringify({ productId: Number(productId), placementType: 'front', variants: map }, null, 2));
+  console.log(`\nOur color ids are white/heather/sand/sky and sizes S/M/L/XL/2XL — align the keys, then paste into the garment's "printful" block in data/wear-templates.json.`);
+}
+main().catch(e => { console.error(e.message); process.exit(1); });
diff --git a/scripts/submit-pod-draft.mjs b/scripts/submit-pod-draft.mjs
index 0c63c88..d685277 100644
--- a/scripts/submit-pod-draft.mjs
+++ b/scripts/submit-pod-draft.mjs
@@ -1,13 +1,18 @@
 #!/usr/bin/env node
-// Steve-gated sender for POD (print-on-demand) order drafts.
+// Steve-gated PRINTFUL sender for POD (print-on-demand) order drafts.
 //
-// The /wear checkout appends a DRAFT_UNSENT record per paid TEST order to
-// data/pod-order-drafts.jsonl. This script is the ONLY place a draft would ever
-// leave the building — and it refuses to unless BOTH:
-//   --apply is passed, AND a POD API key is present (POD_API_KEY env / .env).
-// Without those it is a dry run: it prints exactly what WOULD be sent, so the
-// left-chest payload can be reviewed. No live POD integration is wired yet
-// (Steve's call, 2026-08-06: "template + mockup only, no live wiring").
+// The /wear checkout appends a DRAFT_UNSENT record per PAID order to
+// data/pod-order-drafts.jsonl. This script is the ONLY place a draft ever leaves
+// the building — and it refuses unless ALL THREE are true (defense in depth):
+//   1. --apply is passed,
+//   2. PRINTFUL_API_KEY is present (env or .env),
+//   3. WEAR_SALES_LIVE=1 (real sales are actually turned on).
+// Without all three it is a DRY RUN: it prints the exact Printful /orders payload
+// it WOULD POST, so the left-chest order can be reviewed before anything is sent.
+//
+// Even when applied, orders are created with confirm=false — i.e. a DRAFT order in
+// the Printful dashboard, NOT auto-fulfilled and NOT auto-charged. A human confirms
+// each order in Printful. Provider: PRINTFUL (Steve's call 2026-08-10, TK-10286).
 import { readFile } from 'node:fs/promises';
 import { fileURLToPath } from 'node:url';
 import { join } from 'node:path';
@@ -16,40 +21,88 @@ import { readFileSync } from 'node:fs';
 const ROOT = fileURLToPath(new URL('..', import.meta.url));
 const DATA = join(ROOT, 'data');
 const APPLY = process.argv.includes('--apply');
+const PRINTFUL_ORDERS_URL = 'https://api.printful.com/orders';
 
 function envVal(name) {
   if (process.env[name]) return process.env[name];
   try { const m = readFileSync(join(ROOT, '.env'), 'utf8').match(new RegExp('^' + name + '=(.+)$', 'm')); if (m) return m[1].trim(); } catch {}
+  // also read the master secrets .env if present
+  try { const p = join(ROOT, '..', 'secrets-manager', '.env'); const m = readFileSync(p, 'utf8').match(new RegExp('^' + name + '=(.+)$', 'm')); if (m) return m[1].trim(); } catch {}
   return null;
 }
 
+async function templates() {
+  return JSON.parse(await readFile(join(DATA, 'wear-templates.json'), 'utf8'));
+}
+
+// Resolve our (garment,color,size) -> Printful catalog variant_id. FAILS LOUD when
+// unmapped so an unmapped garment can never be submitted (the map is filled by
+// scripts/fetch-printful-catalog.mjs once PRINTFUL_API_KEY lands).
+function resolveVariant(tpl, d) {
+  const g = (tpl.garments || []).find(x => x.id === d.garment);
+  if (!g) throw new Error(`order #${d.orderId}: unknown garment "${d.garment}"`);
+  const pf = g.printful || {};
+  const key = `${d.color}/${d.size}`;
+  const vid = (pf.variants || {})[key];
+  if (!vid) throw new Error(`order #${d.orderId}: garment "${d.garment}" ${key} is not mapped to a Printful variant_id — run scripts/fetch-printful-catalog.mjs after PRINTFUL_API_KEY is set, then fill data/wear-templates.json.`);
+  return { variantId: vid, placementType: pf.placementType || 'front' };
+}
+
+function buildPayload(tpl, d) {
+  const { variantId, placementType } = resolveVariant(tpl, d);
+  const r = d.recipient || {};
+  // Printful requires a real shipping address for a physical order.
+  const missing = ['name', 'address1', 'city', 'state_code', 'country_code', 'zip'].filter(k => !r[k]);
+  if (missing.length) throw new Error(`order #${d.orderId}: missing shipping fields ${missing.join(',')} — the Stripe checkout must collect a shipping address (shipping_address_collection) and /wear-success must carry it into the draft before this can submit.`);
+  if (!d.design_image_url) throw new Error(`order #${d.orderId}: no design_image_url`);
+  return {
+    external_id: `celebsig-wear-${d.orderId}`,
+    recipient: {
+      name: r.name, email: d.recipient_email,
+      address1: r.address1, address2: r.address2 || '',
+      city: r.city, state_code: r.state_code, country_code: r.country_code, zip: r.zip,
+    },
+    items: [{
+      variant_id: variantId, quantity: 1,
+      name: `${d.signature_name} signature — left-chest ${d.garment}`,
+      files: [{ type: placementType, url: d.design_image_url }],
+    }],
+  };
+}
+
 async function main() {
   let lines = [];
   try { lines = (await readFile(join(DATA, 'pod-order-drafts.jsonl'), 'utf8')).trim().split('\n').filter(Boolean); } catch {}
   const drafts = lines.map(l => JSON.parse(l)).filter(d => d.status === 'DRAFT_UNSENT');
-  console.log(`POD drafts pending: ${drafts.length}`);
-  for (const d of drafts){
-    // The shape a Printful/Printify order will take (documented target payload).
-    const podPayload = {
-      recipient: { email: d.recipient_email },
-      items: [{ garment: d.garment, color: d.color, size: d.size,
-                placement: d.placement, // 'left_chest'
-                design_url: d.design_image_url }],
-      note: `Celebrity signature: ${d.signature_name}`,
-    };
+  const tpl = await templates();
+  console.log(`POD drafts pending: ${drafts.length} (provider: Printful)`);
+
+  const key = envVal('PRINTFUL_API_KEY');
+  const salesLive = envVal('WEAR_SALES_LIVE') === '1';
+  const willApply = APPLY && !!key && salesLive;
+
+  for (const d of drafts) {
     console.log(`\n— order #${d.orderId} (${d.signature_name}) —`);
-    console.log(JSON.stringify(podPayload, null, 2));
+    let payload;
+    try { payload = buildPayload(tpl, d); }
+    catch (e) { console.log(`  SKIP: ${e.message}`); continue; }
+    console.log(JSON.stringify(payload, null, 2));
+    if (!willApply) continue;
+    // confirm=false => Printful DRAFT order (not auto-fulfilled / not auto-charged).
+    const resp = await fetch(PRINTFUL_ORDERS_URL + '?confirm=false', {
+      method: 'POST',
+      headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
+      body: JSON.stringify(payload),
+    });
+    const j = await resp.json().catch(() => ({}));
+    if (!resp.ok) { console.error(`  PRINTFUL ERROR ${resp.status}: ${j.error?.message || j.result || 'unknown'}`); continue; }
+    console.log(`  ✓ Printful DRAFT order created: id=${j.result?.id} status=${j.result?.status} (confirm it in the Printful dashboard to fulfill).`);
   }
-  const key = envVal('POD_API_KEY');
-  if (!APPLY || !key){
-    console.log(`\nDRY RUN — nothing submitted.`);
-    console.log(`  --apply passed: ${APPLY} | POD_API_KEY present: ${!!key}`);
-    console.log(`  To actually submit you need Steve's go, a POD account + POD_API_KEY, and the`);
-    console.log(`  provider call wired below. This is intentionally a stub.`);
-    return;
+
+  if (!willApply) {
+    console.log(`\nDRY RUN — nothing submitted to Printful.`);
+    console.log(`  --apply: ${APPLY} | PRINTFUL_API_KEY present: ${!!key} | WEAR_SALES_LIVE=1: ${salesLive}`);
+    console.log(`  All three must be true to submit. Even then, orders are created as Printful DRAFTS (confirm=false) and a human confirms each one.`);
   }
-  // GATED: real submission is deliberately not implemented. Reaching here needs a
-  // wired provider client; fail loud rather than silently no-op.
-  throw new Error('Live POD submission is not wired yet — add the provider client here (Steve-gated).');
 }
-main().catch(e=>{ console.error(e.message); process.exit(1); });
+main().catch(e => { console.error(e.message); process.exit(1); });
diff --git a/server.js b/server.js
index cb17d18..fefb588 100644
--- a/server.js
+++ b/server.js
@@ -814,6 +814,8 @@ ${paid ? `<div class="ok">✓</div><h1>Order confirmed</h1>
       list.push(order); await store('wear-orders.json', list);
       const params = new URLSearchParams();
       params.set('mode', 'payment');
+      // Physical POD product => collect a shipping address (Printful requires one).
+      params.set('shipping_address_collection[allowed_countries][0]', 'US');
       params.set('success_url', 'https://celebsignatures.com/wear-success?sid={CHECKOUT_SESSION_ID}');
       params.set('cancel_url', 'https://celebsignatures.com/wear');
       params.set('customer_email', email);
@@ -848,16 +850,21 @@ ${paid ? `<div class="ok">✓</div><h1>Order confirmed</h1>
             order = list.find(o => String(o.id) === String(s.metadata?.order_id || s.client_reference_id));
             if (order && order.status !== 'paid') {
               order.status = 'paid'; order.paidAt = new Date().toISOString();
+              // Capture the shipping address Stripe collected (Printful needs it).
+              const sd = s.shipping_details || s.customer_details || {};
+              const ad = sd.address || {};
+              order.recipient = { name: sd.name || null, address1: ad.line1 || null, address2: ad.line2 || null,
+                city: ad.city || null, state_code: ad.state || null, country_code: ad.country || null, zip: ad.postal_code || null };
               // DRAFT the POD order — do NOT submit. This is the exact payload a
-              // future Printful/Printify call will send; scripts/submit-pod-draft.mjs
-              // is the Steve-gated sender.
+              // future Printful call will send; scripts/submit-pod-draft.mjs
+              // is the Steve-gated sender (Printful, confirm=false drafts).
               if (!order.pod_submitted) {
                 const sig = (await mergedSignatures()).find(x => qidOf(x) === order.qid);
-                const draft = { draftedAt: new Date().toISOString(), orderId: order.id, status: 'DRAFT_UNSENT',
-                  recipient_email: order.email, garment: order.garment, color: order.color, size: order.size,
+                const draft = { draftedAt: new Date().toISOString(), orderId: order.id, status: 'DRAFT_UNSENT', provider: 'printful',
+                  recipient_email: order.email, recipient: order.recipient, garment: order.garment, color: order.color, size: order.size,
                   placement: order.placement, signature_name: order.signature_name,
                   design_image_url: sig ? sig.signature_image_url : null,
-                  note: 'NOT SENT — awaiting Steve approval + POD API key (scripts/submit-pod-draft.mjs --apply)' };
+                  note: 'NOT SENT — awaiting Steve approval + PRINTFUL_API_KEY + WEAR_SALES_LIVE=1 (scripts/submit-pod-draft.mjs --apply)' };
                 await appendFile(join(DATA, 'pod-order-drafts.jsonl'), JSON.stringify(draft) + '\n');
                 order.pod_submitted = true;
               }

← 5ed1ce7 wear v1 soft-launch: browsable catalog live, sales gated beh  ·  back to CelebritySignatures  ·  chore: lint, refactor, session-close fixes, v1.0.2 → v1.0.3 0770f00 →