[object Object]

← back to Dw Signup Fulfillment

honor-reissue: void+re-mint 53 orphaned free-sample cards (persist codes), George verify script, disableGiftCard helper

bf4aa2cfa3cc7c4b11c84d9671d008cd61076fe0 · 2026-08-14 11:22:16 -0700 · steve

Files touched

Diff

commit bf4aa2cfa3cc7c4b11c84d9671d008cd61076fe0
Author: steve <steve@designerwallcoverings.com>
Date:   Fri Aug 14 11:22:16 2026 -0700

    honor-reissue: void+re-mint 53 orphaned free-sample cards (persist codes), George verify script, disableGiftCard helper
---
 .gitignore               |   1 +
 lib/shopify.js           |  11 +++-
 scripts/george-verify.js |  40 ++++++++++++++
 scripts/honor-reissue.js | 140 +++++++++++++++++++++++++++++++++++++++++++++++
 4 files changed, 191 insertions(+), 1 deletion(-)

diff --git a/.gitignore b/.gitignore
index c541f8f..909bc1e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -16,3 +16,4 @@ data/rep-cursor.json
 __pycache__/
 *.pyc
 *.bak
+data/minted-cards-honor-worklist.jsonl
diff --git a/lib/shopify.js b/lib/shopify.js
index 70e9ee4..2c3c040 100644
--- a/lib/shopify.js
+++ b/lib/shopify.js
@@ -83,6 +83,15 @@ async function createGiftCard({ value, note, currency, code }) {
   return request('POST', '/gift_cards.json', { gift_card });
 }
 
+// Disable (void) a gift card by id — POST /gift_cards/<id>/disable.json. This is
+// IRREVERSIBLE in Shopify (a disabled card can never be re-enabled) and zeroes the
+// card's redeemable balance, which is exactly what clears the phantom liability of
+// the 53 orphaned free-sample cards whose codes were never persisted. Being a POST,
+// it short-circuits safely under DRY_RUN via request().
+async function disableGiftCard(id) {
+  return request('POST', `/gift_cards/${id}/disable.json`, { gift_card: { id } });
+}
+
 // Add tags to a customer (tagsAdd equivalent — REST needs the merged tag string,
 // so the caller passes the already-merged value; addTags() merges for you).
 async function updateCustomer(customerId, fields) {
@@ -185,6 +194,6 @@ async function graphql(query, variables, opts = {}) {
 }
 
 module.exports = {
-  request, graphql, createGiftCard, updateCustomer, getCustomer, getCustomerMetafield, findCustomerByEmail, addTags,
+  request, graphql, createGiftCard, disableGiftCard, updateCustomer, getCustomer, getCustomerMetafield, findCustomerByEmail, addTags,
   setCustomerMetafield, createWebhook, base,
 };
diff --git a/scripts/george-verify.js b/scripts/george-verify.js
new file mode 100644
index 0000000..2785dd0
--- /dev/null
+++ b/scripts/george-verify.js
@@ -0,0 +1,40 @@
+#!/usr/bin/env node
+'use strict';
+// One-shot verification that George's external-send path is healthy.
+// Reads the static GEORGE_EXTERNAL_SEND_TOKEN + basic auth straight from
+// ~/Projects/george-gmail/.env (never printed), then does ONE external send to
+// claude@agentabrams.com (external → exercises the X-Send-Approval guard; lands
+// in Steve's agent inbox, bothers no customer). Prints HTTP status + response.
+//   Expected: HTTP 200 and no "blocked" field  → token path healthy.
+//   HTTP 403 blocked                            → token mismatch (would need a fix).
+// Run:  node ~/Projects/dw-signup-fulfillment/scripts/george-verify.js
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+
+const envPath = path.join(os.homedir(), 'Projects', 'george-gmail', '.env');
+const env = fs.readFileSync(envPath, 'utf8');
+const pick = (k) => { const m = env.match(new RegExp('^' + k + '=(.+)$', 'm')); return m ? m[1].trim().replace(/^["']|["']$/g, '') : ''; };
+
+const token = pick('GEORGE_EXTERNAL_SEND_TOKEN');
+let auth = pick('GEORGE_BASIC_AUTH') || 'admin:';
+const basic = auth.startsWith('Basic ') ? auth : 'Basic ' + Buffer.from(auth.includes(':') ? auth : 'admin:' + auth).toString('base64');
+
+console.log('token:', token ? 'SET (last4 ...' + token.slice(-4) + ')' : 'EMPTY');
+
+fetch('http://127.0.0.1:9850/api/send', {
+  method: 'POST',
+  headers: { 'Content-Type': 'application/json', Authorization: basic, 'X-Send-Approval': token },
+  body: JSON.stringify({
+    account: 'steve-office',
+    from: 'info@designerwallcoverings.com',
+    to: 'claude@agentabrams.com',
+    subject: '[George guard test] external-send verification',
+    body: '<p>George external-send guard verification. If this lands, the GEORGE_EXTERNAL_SEND_TOKEN path is healthy and dw-signup welcome emails can flow.</p>',
+  }),
+  signal: AbortSignal.timeout(30000),
+}).then((r) => r.json().catch(() => ({})).then((j) => {
+  console.log('HTTP', r.status);
+  console.log('resp', JSON.stringify(j).slice(0, 400));
+  console.log(r.ok && !j.blocked && !j.error ? '\n✅ George external-send path is HEALTHY — no token fix needed.' : '\n❌ Send did not go through — see resp above.');
+})).catch((e) => console.log('ERROR', e.message));
diff --git a/scripts/honor-reissue.js b/scripts/honor-reissue.js
new file mode 100644
index 0000000..9fdc3f3
--- /dev/null
+++ b/scripts/honor-reissue.js
@@ -0,0 +1,140 @@
+#!/usr/bin/env node
+'use strict';
+// honor-reissue.js — honor the 53 orphaned free-sample gift cards (TK-10580).
+//
+// Context: 53 customers signed up and the OLD webhook minted them a $12.75 gift card,
+// but George silently 403'd the delivery email AND the client-generated codes were never
+// persisted — so the cards exist as pure phantom liability nobody can redeem. Steve's call
+// (2026-08-14): VOID + RE-MINT. For each of the 53:
+//   1. mint a FRESH $12.75 card with a branded DW###### code,
+//   2. PERSIST the new code to a durable ledger IMMEDIATELY (the lesson from this incident:
+//      a code you don't write down at creation is gone — Shopify only shows it once),
+//   3. email the customer their new code (external send → needs George's approval token),
+//   4. THEN void the old orphaned card (irreversible; pure liability cleanup, safe to retry).
+// This order guarantees a customer always has a usable, delivered card before the old one dies.
+//
+// SAFETY:
+//   • Honors config.DRY_RUN — with DRY_RUN=1 nothing mints/voids/sends; it prints the plan.
+//     Go live by running with DRY_RUN=0 in the env (the service default is DRY_RUN=1).
+//   • Idempotent: a customer already marked minted in the ledger is skipped (no double cards).
+//   • --limit N  → process only the first N pending (canary; do 1, verify, then the rest).
+//
+// Run (dry):   node scripts/honor-reissue.js
+// Canary live: DRY_RUN=0 node scripts/honor-reissue.js --limit 1
+// Full live:   DRY_RUN=0 node scripts/honor-reissue.js
+const fs = require('fs');
+const path = require('path');
+const config = require('../lib/config');
+const shopify = require('../lib/shopify');
+const email = require('../lib/email');
+
+const WORKLIST = path.join(__dirname, '..', 'data', 'minted-cards-honor-worklist.jsonl');
+const LEDGER = path.join(__dirname, '..', 'data', 'honor-reissue-ledger.jsonl');
+const VALUE = +(config.FREE_SAMPLE_COUNT * config.SAMPLE_PRICE).toFixed(2); // 3 × 4.25 = 12.75
+
+const limitArg = process.argv.indexOf('--limit');
+const LIMIT = limitArg > -1 ? parseInt(process.argv[limitArg + 1], 10) : Infinity;
+
+// Branded, unambiguous short code: DW + 6 chars (no O/0/I/1/L). Same scheme the old minter used.
+function shortGiftCode() {
+  const A = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789';
+  let s = 'DW';
+  for (let i = 0; i < 6; i++) s += A[Math.floor(Math.random() * A.length)];
+  return s;
+}
+// First name only, title case (per Steve's welcome-email rule): Shopify first_name if present,
+// else the leading token of the email local-part.
+function firstNameOf(customer, emailAddr) {
+  const raw = (customer && customer.first_name) || (emailAddr || '').split('@')[0].split(/[._-]/)[0] || 'there';
+  return raw.charAt(0).toUpperCase() + raw.slice(1).toLowerCase();
+}
+// Internal/test signups (our own go-live tests, plus-addressed or @designerwallcoverings.com)
+// are NOT real customers owed samples — for those we VOID the orphaned card for cleanup but
+// do not re-mint a real $12.75 card or send a welcome email.
+function isInternalOrTest(emailAddr) {
+  const e = (emailAddr || '').toLowerCase();
+  return /@designerwallcoverings\.com$/.test(e) || /dwgolive|\btest@|example\.com$/.test(e) || /\+dwgolive/.test(e);
+}
+function loadLedger() {
+  const done = new Map();
+  try {
+    for (const line of fs.readFileSync(LEDGER, 'utf8').split('\n')) {
+      if (!line.trim()) continue;
+      const r = JSON.parse(line);
+      done.set(String(r.customer_id), r);
+    }
+  } catch {}
+  return done;
+}
+function appendLedger(row) {
+  fs.mkdirSync(path.dirname(LEDGER), { recursive: true });
+  fs.appendFileSync(LEDGER, JSON.stringify(row) + '\n');
+}
+
+async function main() {
+  const rows = fs.readFileSync(WORKLIST, 'utf8').split('\n').filter((l) => l.trim()).map((l) => JSON.parse(l));
+  const done = loadLedger();
+  const pending = rows.filter((r) => { const d = done.get(String(r.customer_id)) || {}; return !d.minted && !d.finalized; });
+
+  console.log(`honor-reissue — ${rows.length} on worklist · ${rows.length - pending.length} already done · ${pending.length} pending`);
+  console.log(`mode: ${config.DRY_RUN ? 'DRY_RUN (no writes/sends)' : 'LIVE'} · value/card: $${VALUE} · 💰 est new liability this run: $${(Math.min(pending.length, LIMIT) * VALUE).toFixed(2)} (replaces the equal voided amount)`);
+
+  let n = 0, minted = 0, emailed = 0, voided = 0;
+  for (const r of pending) {
+    if (n >= LIMIT) break;
+    n++;
+    const cid = String(r.customer_id);
+    const to = r.email;
+    const row = { customer_id: cid, email: to, old_card_id: r.card_id, ts: new Date().toISOString(), dry: !!config.DRY_RUN };
+
+    // Internal/test signup → void the orphaned card only, no reissue/email.
+    if (isInternalOrTest(to)) {
+      const dis = await shopify.disableGiftCard(r.card_id);
+      const ok = dis && (dis.ok !== false);
+      if (ok) voided++;
+      appendLedger({ ...row, internal_test: true, minted: false, emailed: false, voided: ok, finalized: true });
+      console.log(`  [${n}] ${to.replace(/(.{2}).*@/, '$1***@')} → INTERNAL/TEST: void-only=${ok} (no reissue/email)`);
+      continue;
+    }
+
+    // name (read-only; safe in any mode)
+    let cust = null;
+    try { const g = await shopify.getCustomer(cid); cust = g && g.json && g.json.customer; } catch {}
+    const firstName = firstNameOf(cust, to);
+
+    // 1) mint fresh card
+    const code = shortGiftCode();
+    const gc = await shopify.createGiftCard({ value: VALUE, note: `DW free samples — reissue honoring orphaned card ${r.card_id} (${to})`, code });
+    const card = (gc && gc.json && gc.json.gift_card) || {};
+    const newCode = card.code ? String(card.code).toUpperCase() : code;
+    row.new_card_id = card.id || null;
+    row.new_code = newCode;
+    row.minted = gc && (gc.ok !== false);
+    if (row.minted) minted++;
+
+    // 2) PERSIST immediately (before emailing — never lose a code again)
+    appendLedger({ ...row, emailed: false, voided: false });
+
+    // 3) email the customer their new code
+    const tpl = email.retailGiftEmail({ firstName, code: newCode, value: VALUE, count: config.FREE_SAMPLE_COUNT });
+    const mail = await email.sendEmail({ to, subject: tpl.subject, html: tpl.html, source: 'honor-reissue' });
+    row.emailed = mail && (mail.ok !== false) && !mail.blocked;
+    if (row.emailed) emailed++;
+    else console.warn(`  ⚠️  email NOT delivered to ${to.replace(/(.{2}).*@/, '$1***@')} (${mail && (mail.reason || mail.error || mail.status) || 'unknown'})`);
+
+    // 4) void the old orphaned card (irreversible; retry-safe)
+    const dis = await shopify.disableGiftCard(r.card_id);
+    row.voided = dis && (dis.ok !== false);
+    if (row.voided) voided++;
+
+    // final ledger line reflecting delivery + void outcome
+    appendLedger({ ...row, finalized: true });
+    console.log(`  [${n}/${Math.min(pending.length, LIMIT)}] ${to.replace(/(.{2}).*@/, '$1***@')} → mint=${row.minted} email=${row.emailed} void=${row.voided} code=${newCode}`);
+  }
+
+  console.log(`\nDONE (${config.DRY_RUN ? 'DRY_RUN' : 'LIVE'}): processed ${n} · minted ${minted} · emailed ${emailed} · voided ${voided}`);
+  console.log(`ledger: ${LEDGER}`);
+  if (!config.DRY_RUN && emailed < n) console.log('⚠️  some emails did not deliver — check George / the send-approval token before re-running (idempotent: minted rows are skipped).');
+}
+
+main().catch((e) => { console.error('FATAL', e); process.exit(1); });

← 43065de retail-webhook: only set sample_verify_sent flag when the le  ·  back to Dw Signup Fulfillment  ·  honor-reissue: explicit --apply flag to go live (clean comma 3658732 →