[object Object]

← back to CelebritySignatures

wear/POD: fix 4 correctness bugs from codex's 2026-09-04 review (TK-10286)

f197cfcaa0df67513b115e7a94c76ddbd8129675 · 2026-09-08 06:47:25 -0700 · Steve Abrams

All still gated OFF (WEAR_SALES_LIVE unset, no variant mappings) — these are
hardening fixes for whenever go-live happens, not activation.

1. Pin POD provider on the order at checkout time instead of re-reading
   WEAR_POD_PROVIDER at /wear-success, so an env change between checkout and
   success can never route a paid order to the wrong provider's sender.
2. wearPodReady() (checkout readiness) now also requires PRINTIFY_SHOP_ID +
   PRINTIFY_MANUAL_APPROVAL_CONFIRMED=1 for the printify path, matching the
   5-gate check submit-pod-draft-printify.mjs already enforces — a paid order
   can no longer sit forever as a DRAFT_UNSENT the sender will never touch.
3. Idempotent draft-append guard in /wear-success (checks the drafts log for
   an existing orderId before appending) + orderId-dedupe defense-in-depth in
   both submit-pod-draft*.mjs senders, so a crash/retry window can never
   produce two DRAFT_UNSENT lines for one order and double-submit it.
4. Dry-run payload previews (the default mode) now redact recipient PII
   (name/email/address/zip) by default; --show-pii opts back in for real
   debugging. Previously a dry-run printed full customer addresses to stdout.

New: scripts/reconcile-wear-orders.mjs — closes the "customer paid but never
reached /wear-success" gap (browser closed, redirect never lands) by
re-checking pending_payment orders with a stripeSession against Stripe
(read-only GET) and running the same draft logic on anything Stripe reports
paid. Dry-run by default; --apply persists. No webhook, no new config.

Verified: node --check clean on all 4 files; local E2E (throwaway port) shows
2955 signatures, purchasable:false, checkout still returns comingSoon:true,
home/wear both 200; all three POD scripts dry-run to zero-fire.

Co-authored with a concurrent claude-run-10286 session (celebritysignatures-50)
that independently landed items 1-4; this commit also adds the printify-sender
dedupe (item 3b) it hadn't covered and merges both sessions' work.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmxcNKtFg77wP47uZq5Zsm

Files touched

Diff

commit f197cfcaa0df67513b115e7a94c76ddbd8129675
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Sep 8 06:47:25 2026 -0700

    wear/POD: fix 4 correctness bugs from codex's 2026-09-04 review (TK-10286)
    
    All still gated OFF (WEAR_SALES_LIVE unset, no variant mappings) — these are
    hardening fixes for whenever go-live happens, not activation.
    
    1. Pin POD provider on the order at checkout time instead of re-reading
       WEAR_POD_PROVIDER at /wear-success, so an env change between checkout and
       success can never route a paid order to the wrong provider's sender.
    2. wearPodReady() (checkout readiness) now also requires PRINTIFY_SHOP_ID +
       PRINTIFY_MANUAL_APPROVAL_CONFIRMED=1 for the printify path, matching the
       5-gate check submit-pod-draft-printify.mjs already enforces — a paid order
       can no longer sit forever as a DRAFT_UNSENT the sender will never touch.
    3. Idempotent draft-append guard in /wear-success (checks the drafts log for
       an existing orderId before appending) + orderId-dedupe defense-in-depth in
       both submit-pod-draft*.mjs senders, so a crash/retry window can never
       produce two DRAFT_UNSENT lines for one order and double-submit it.
    4. Dry-run payload previews (the default mode) now redact recipient PII
       (name/email/address/zip) by default; --show-pii opts back in for real
       debugging. Previously a dry-run printed full customer addresses to stdout.
    
    New: scripts/reconcile-wear-orders.mjs — closes the "customer paid but never
    reached /wear-success" gap (browser closed, redirect never lands) by
    re-checking pending_payment orders with a stripeSession against Stripe
    (read-only GET) and running the same draft logic on anything Stripe reports
    paid. Dry-run by default; --apply persists. No webhook, no new config.
    
    Verified: node --check clean on all 4 files; local E2E (throwaway port) shows
    2955 signatures, purchasable:false, checkout still returns comingSoon:true,
    home/wear both 200; all three POD scripts dry-run to zero-fire.
    
    Co-authored with a concurrent claude-run-10286 session (celebritysignatures-50)
    that independently landed items 1-4; this commit also adds the printify-sender
    dedupe (item 3b) it hadn't covered and merges both sessions' work.
    
    Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01AmxcNKtFg77wP47uZq5Zsm
---
 scripts/reconcile-wear-orders.mjs     | 115 ++++++++++++++++++++++++++++++++++
 scripts/submit-pod-draft-printify.mjs |  22 ++++++-
 scripts/submit-pod-draft.mjs          |  29 ++++++++-
 server.js                             |  46 +++++++++++---
 4 files changed, 199 insertions(+), 13 deletions(-)

diff --git a/scripts/reconcile-wear-orders.mjs b/scripts/reconcile-wear-orders.mjs
new file mode 100644
index 0000000..efb09f8
--- /dev/null
+++ b/scripts/reconcile-wear-orders.mjs
@@ -0,0 +1,115 @@
+#!/usr/bin/env node
+// Reconciliation backstop for /wear orders (TK-10286).
+//
+// /wear-success is the ONLY place a paid Stripe session gets turned into a
+// POD-order draft — and it only runs when the customer's browser actually
+// GETs that URL. If a customer closes the tab after paying, or Stripe's
+// redirect never lands (network blip, app switch), the order sits forever in
+// status:'pending_payment' with a real charge behind it and no draft ever
+// created — a charged-but-unfulfilled order with no code path to catch it.
+//
+// This script closes that gap without a webhook: it re-checks every
+// pending_payment order that has a stripeSession id against Stripe directly,
+// and for any that Stripe reports as paid, runs the SAME draft logic
+// /wear-success runs (persist paid status + shipping + an idempotent
+// DRAFT_UNSENT append) using the provider PINNED on the order at checkout.
+//
+// Read-only against Stripe (GET only, no charges, no POD submission — that
+// stays scripts/submit-pod-draft*.mjs, separately gated). Local-only writes
+// (data/wear-orders.json, data/pod-order-drafts.jsonl), fully idempotent, and
+// reversible via git/backups the same way every other local JSON write here
+// is. Safe to run repeatedly; safe to schedule.
+//
+// Usage: node scripts/reconcile-wear-orders.mjs [--apply]
+//   (no flag) — report what WOULD change, write nothing.
+//   --apply   — persist the paid status + draft to disk.
+import { readFile, appendFile, writeFile } from 'node:fs/promises';
+import { readFileSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import { join } from 'node:path';
+
+const ROOT = fileURLToPath(new URL('..', import.meta.url));
+const DATA = join(ROOT, 'data');
+const APPLY = process.argv.includes('--apply');
+
+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().replace(/^["']|["']$/g, ''); } catch {}
+  }
+  return null;
+}
+// Mirrors server.js's STRIPE_MURAL_KEY resolver exactly (live only when both
+// STRIPE_LIVE_ENABLED=1 and a real sk_live_/rk_live_ key are present).
+function resolveStripeKey() {
+  const liveEnabled = envVal('STRIPE_LIVE_ENABLED') === '1';
+  const testKey = (() => { const k = envVal('STRIPE_TEST_SECRET_KEY'); return k && k.startsWith('sk_test_') ? k : null; })();
+  const liveKey = (() => { const k = envVal('STRIPE_LIVE_SECRET_KEY'); return k && /^(sk|rk)_live_/.test(k) ? k : null; })();
+  return liveEnabled && liveKey ? liveKey : testKey;
+}
+async function load(name, fallback) { try { return JSON.parse(await readFile(join(DATA, name), 'utf8')); } catch { return fallback; } }
+async function store(name, val) { await writeFile(join(DATA, name), JSON.stringify(val, null, 2)); }
+const qidOf = r => (r.wikidata || '').split('/').pop();
+async function mergedSignatures() {
+  const base = await load('celebrity_signatures.json', []);
+  const artists = await load('artists.json', []);
+  const authors = await load('authors.json', []);
+  return [...base, ...artists, ...authors];
+}
+
+async function main() {
+  const key = resolveStripeKey();
+  if (!key) { console.log('No Stripe key resolvable (test or live) — nothing to reconcile against.'); return; }
+  const orders = await load('wear-orders.json', []);
+  const pending = orders.filter(o => o.status === 'pending_payment' && o.stripeSession);
+  console.log(`wear-orders: ${orders.length} total, ${pending.length} pending_payment with a Stripe session to re-check.`);
+  if (!pending.length) return;
+
+  let existingDraftLines = [];
+  try { existingDraftLines = (await readFile(join(DATA, 'pod-order-drafts.jsonl'), 'utf8')).trim().split('\n').filter(Boolean); } catch {}
+  const alreadyDraftedIds = new Set(existingDraftLines.map(l => { try { return JSON.parse(l).orderId; } catch { return null; } }));
+
+  let sigCache = null;
+  let changed = 0, newlyPaid = 0, stillUnpaid = 0, errors = 0;
+
+  for (const order of pending) {
+    let s;
+    try {
+      const resp = await fetch(`https://api.stripe.com/v1/checkout/sessions/${order.stripeSession}`, { headers: { Authorization: `Bearer ${key}` } });
+      s = await resp.json();
+      if (!resp.ok) throw new Error(s.error?.message || `HTTP ${resp.status}`);
+    } catch (e) { console.log(`  order #${order.id}: Stripe lookup failed — ${e.message}`); errors++; continue; }
+
+    if (s.payment_status !== 'paid') { stillUnpaid++; continue; }
+
+    newlyPaid++;
+    console.log(`  order #${order.id} (${order.signature_name}): Stripe reports PAID, but no draft was recorded — reconciling.`);
+    if (!APPLY) continue;
+
+    order.status = 'paid'; order.paidAt = new Date().toISOString();
+    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 };
+    const provider = order.provider || 'printful';
+    if (!order.pod_submitted && !alreadyDraftedIds.has(order.id)) {
+      if (!sigCache) sigCache = await mergedSignatures();
+      const sig = sigCache.find(x => qidOf(x) === order.qid);
+      const draft = { draftedAt: new Date().toISOString(), orderId: order.id, status: 'DRAFT_UNSENT', provider,
+        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 + ${provider} credentials/mapping + WEAR_SALES_LIVE=1 (drafted by reconcile-wear-orders.mjs — customer never reached /wear-success)` };
+      await appendFile(join(DATA, 'pod-order-drafts.jsonl'), JSON.stringify(draft) + '\n');
+      alreadyDraftedIds.add(order.id);
+    }
+    order.pod_submitted = true;
+    changed++;
+  }
+
+  if (APPLY && changed) await store('wear-orders.json', orders);
+  console.log(`\nSummary: ${newlyPaid} newly-paid found, ${stillUnpaid} still unpaid, ${errors} lookup errors.`);
+  if (!APPLY) console.log(newlyPaid ? 'DRY RUN — re-run with --apply to persist the paid status + draft the POD order.' : 'DRY RUN — nothing to reconcile.');
+  else console.log(`Applied: ${changed} order(s) updated. No POD order was submitted — that stays scripts/submit-pod-draft*.mjs, separately gated.`);
+}
+main().catch(e => { console.error(e.message); process.exit(1); });
diff --git a/scripts/submit-pod-draft-printify.mjs b/scripts/submit-pod-draft-printify.mjs
index bfc76f6..d94bb5f 100644
--- a/scripts/submit-pod-draft-printify.mjs
+++ b/scripts/submit-pod-draft-printify.mjs
@@ -13,6 +13,16 @@ const ROOT = fileURLToPath(new URL('..', import.meta.url));
 const DATA = join(ROOT, 'data');
 const API = 'https://api.printify.com/v1';
 const APPLY = process.argv.includes('--apply');
+const SHOW_PII = process.argv.includes('--show-pii');
+
+// Don't let a real customer's name/address/email land in a terminal
+// scrollback or redirected log file by default; --show-pii opts back in.
+function redactPayload(payload) {
+  if (SHOW_PII) return payload;
+  const a = payload.address_to || {};
+  return { ...payload, address_to: { ...a, first_name: a.first_name ? '[redacted]' : a.first_name, last_name: a.last_name ? '[redacted]' : a.last_name,
+    email: a.email ? '[redacted]' : a.email, address1: a.address1 ? '[redacted]' : a.address1, address2: a.address2 ? '[redacted]' : a.address2, zip: a.zip ? '[redacted]' : a.zip } };
+}
 function envVal(name) {
   if (process.env[name]) return process.env[name];
   for (const p of [join(ROOT, '.env'), join(ROOT, '..', 'secrets-manager', '.env')]) {
@@ -60,7 +70,15 @@ async function main() {
   try { lines = (await readFile(join(DATA, 'pod-order-drafts.jsonl'), 'utf8')).trim().split('\n').filter(Boolean); } catch {}
   const all = lines.map(JSON.parse);
   const submitted = new Set(all.filter(x => x.status === 'SUBMITTED' && x.provider === 'printify').map(x => x.orderId));
-  const drafts = all.filter(x => x.status === 'DRAFT_UNSENT' && x.provider === 'printify' && !submitted.has(x.orderId));
+  // Dedupe by orderId (defense-in-depth): two DRAFT_UNSENT lines for the same
+  // order (a crash/retry on the writer side) must never both get submitted —
+  // only the first is ever considered.
+  const seenOrderIds = new Set();
+  const drafts = all.filter(x => {
+    if (x.status !== 'DRAFT_UNSENT' || x.provider !== 'printify' || submitted.has(x.orderId)) return false;
+    if (seenOrderIds.has(x.orderId)) return false;
+    seenOrderIds.add(x.orderId); return true;
+  });
   const tpl = JSON.parse(await readFile(join(DATA, 'wear-templates.json'), 'utf8'));
   const token = envVal('PRINTIFY_API_TOKEN');
   const shopId = envVal('PRINTIFY_SHOP_ID');
@@ -71,7 +89,7 @@ async function main() {
   for (const d of drafts) {
     let payload;
     try { payload = buildPayload(tpl, d); } catch (e) { console.log(`  SKIP: ${e.message}`); continue; }
-    console.log(JSON.stringify(payload, null, 2));
+    console.log(JSON.stringify(redactPayload(payload), null, 2));
     if (!willApply) continue;
     const resp = await fetch(`${API}/shops/${shopId}/orders.json`, { method: 'POST',
       headers: { Authorization: `Bearer ${token}`, 'User-Agent': 'CelebritySignatures/1.0', 'Content-Type': 'application/json' },
diff --git a/scripts/submit-pod-draft.mjs b/scripts/submit-pod-draft.mjs
index 8bdf75c..8203c41 100644
--- a/scripts/submit-pod-draft.mjs
+++ b/scripts/submit-pod-draft.mjs
@@ -21,6 +21,7 @@ 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 SHOW_PII = process.argv.includes('--show-pii');
 const PRINTFUL_ORDERS_URL = 'https://api.printful.com/orders';
 
 function envVal(name) {
@@ -31,6 +32,21 @@ function envVal(name) {
   return null;
 }
 
+// Dry-run preview only — never sent anywhere, never written to a file. Masks
+// recipient PII (name/address/email) so a dry-run (the DEFAULT mode) never
+// puts a real customer's home address into stdout/console logs. Region-level
+// fields (city/state/country) stay visible since that's what a human
+// reviewer needs to sanity-check routing; --show-pii opts into the raw
+// payload for deliberate debugging.
+function redactForLog(payload) {
+  if (SHOW_PII) return payload;
+  const r = payload.recipient || {};
+  const maskName = s => s ? s.trim().split(/\s+/).map(w => w[0] + '.').join(' ') : s;
+  const maskEmail = s => { const m = /^(.)(.*)(@.+)$/.exec(s || ''); return m ? `${m[1]}***${m[3]}` : s; };
+  return { ...payload, recipient: { ...r, name: maskName(r.name), email: maskEmail(r.email),
+    address1: r.address1 ? '[redacted]' : r.address1, address2: r.address2 ? '[redacted]' : r.address2, zip: r.zip ? r.zip.slice(0, 3) + '**' : r.zip } };
+}
+
 async function templates() {
   return JSON.parse(await readFile(join(DATA, 'wear-templates.json'), 'utf8'));
 }
@@ -77,7 +93,16 @@ async function main() {
   // Orders already sent to Printful get a SUBMITTED marker line appended below.
   // Exclude them so a rerun (network glitch, human re-run) NEVER double-submits.
   const submitted = new Set(all.filter(d => d.status === 'SUBMITTED' && (!d.provider || d.provider === 'printful')).map(d => d.orderId));
-  const drafts = all.filter(d => d.status === 'DRAFT_UNSENT' && (!d.provider || d.provider === 'printful') && !submitted.has(d.orderId));
+  // Dedupe by orderId (defense-in-depth): if the append-only drafts log ever
+  // ends up with two DRAFT_UNSENT lines for the same order (a crash/retry on
+  // the writer side), only the FIRST is ever considered — never submit the
+  // same paid order twice to Printful.
+  const seenOrderIds = new Set();
+  const drafts = all.filter(d => {
+    if (d.status !== 'DRAFT_UNSENT' || (d.provider && d.provider !== 'printful') || submitted.has(d.orderId)) return false;
+    if (seenOrderIds.has(d.orderId)) return false;
+    seenOrderIds.add(d.orderId); return true;
+  });
   const tpl = await templates();
   console.log(`POD drafts pending: ${drafts.length} (provider: Printful)`);
 
@@ -90,7 +115,7 @@ async function main() {
     let payload;
     try { payload = buildPayload(tpl, d); }
     catch (e) { console.log(`  SKIP: ${e.message}`); continue; }
-    console.log(JSON.stringify(payload, null, 2));
+    console.log(JSON.stringify(redactForLog(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', {
diff --git a/server.js b/server.js
index 202f51d..71ed5a4 100644
--- a/server.js
+++ b/server.js
@@ -162,6 +162,14 @@ function wearPodReady(tpl, provider) {
   if (!provider) return false;
   const credential = provider === 'printify' ? envVal('PRINTIFY_API_TOKEN') : envVal('PRINTFUL_API_KEY');
   if (!credential) return false;
+  // Printify's sender (scripts/submit-pod-draft-printify.mjs) refuses to POST
+  // unless it ALSO has a shop id + an explicit manual-approval acknowledgement
+  // (5 gates total). Checkout readiness must mirror that, or a paid order can
+  // sit forever as a DRAFT_UNSENT the sender will never touch.
+  if (provider === 'printify') {
+    if (!envVal('PRINTIFY_SHOP_ID')) return false;
+    if (envVal('PRINTIFY_MANUAL_APPROVAL_CONFIRMED') !== '1') return false;
+  }
   return (tpl.garments || []).every(g => {
     const mapping = g[provider] || {};
     if (provider === 'printify' && (!mapping.blueprintId || !mapping.printProviderId)) return false;
@@ -838,7 +846,11 @@ ${paid ? `<div class="ok">✓</div><h1>Order confirmed</h1>
       const order = { id, at: new Date().toISOString(), account: u ? u.email : null, email,
         qid: qidOf(sig), signature_name: sig.full_name, garment: garment.id, garment_label: garment.label,
         color: color.id, size, placement: tpl.placement || 'left_chest', amountUsd: amountCents / 100,
-        status: 'pending_payment', mode: STRIPE_MODE, pod_submitted: false };
+        // Pin the POD provider to the order at the moment of purchase. If
+        // WEAR_POD_PROVIDER (or its readiness) changes before the customer lands
+        // on /wear-success, the draft must still route to the provider that was
+        // actually configured — and eligible — when the charge happened.
+        status: 'pending_payment', mode: STRIPE_MODE, provider: podProvider, pod_submitted: false };
       list.push(order); await store('wear-orders.json', list);
       const params = new URLSearchParams();
       params.set('mode', 'payment');
@@ -884,15 +896,31 @@ ${paid ? `<div class="ok">✓</div><h1>Order confirmed</h1>
               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. The selected provider's
-              // separately gated sender validates and transmits it later.
+              // separately gated sender validates and transmits it later. Use the
+              // provider PINNED on the order at checkout time (falls back to the
+              // live env for orders placed before that field existed) — never
+              // re-read WEAR_POD_PROVIDER fresh here, or a provider switch between
+              // checkout and this GET could route a paid order to a provider whose
+              // variant map doesn't match what the customer actually bought.
+              const provider = order.provider || wearPodProvider();
               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', provider: wearPodProvider(),
-                  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 + ${wearPodProvider()} credentials/mapping + WEAR_SALES_LIVE=1` };
-                await appendFile(join(DATA, 'pod-order-drafts.jsonl'), JSON.stringify(draft) + '\n');
+                // Idempotency guard: if a prior run appended the draft line but
+                // crashed before persisting pod_submitted=true, don't re-append —
+                // check the append-only log itself, not just the in-memory flag.
+                let alreadyDrafted = false;
+                try {
+                  const existingLines = (await readFile(join(DATA, 'pod-order-drafts.jsonl'), 'utf8')).trim().split('\n').filter(Boolean);
+                  alreadyDrafted = existingLines.some(l => { try { return JSON.parse(l).orderId === order.id; } catch { return false; } });
+                } catch {}
+                if (!alreadyDrafted) {
+                  const sig = (await mergedSignatures()).find(x => qidOf(x) === order.qid);
+                  const draft = { draftedAt: new Date().toISOString(), orderId: order.id, status: 'DRAFT_UNSENT', provider,
+                    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 + ${provider} credentials/mapping + WEAR_SALES_LIVE=1` };
+                  await appendFile(join(DATA, 'pod-order-drafts.jsonl'), JSON.stringify(draft) + '\n');
+                }
                 order.pod_submitted = true;
               }
               await store('wear-orders.json', list);

← 919eed1 Celebrity Signatures: correct a FALSE 'Data Not Collected' p  ·  back to CelebritySignatures  ·  wear: fix dark-garment preview + map Printify blueprints/var ba30f90 →