← 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
M .gitignoreM lib/shopify.jsA scripts/george-verify.jsA scripts/honor-reissue.js
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 →