← back to Dw Signup Fulfillment
scripts/honor-reissue.js
158 lines
#!/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
// Go live ONLY with an explicit --apply flag. We flip DRY_RUN off BEFORE requiring config
// (config reads env at load time, and the shopify/email libs short-circuit on config.DRY_RUN),
// so the invocation stays a clean `node scripts/honor-reissue.js --apply ...` with no env
// prefix — which makes it match a simple Bash permission rule. Without --apply it is dry.
if (process.argv.includes('--apply')) process.env.DRY_RUN = '0';
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');
// DRY runs write to a SEPARATE ledger so a dry preview can never mark a real customer
// "done" in the live ledger (which happened once: a dry-verify polluted the live ledger
// and the real --apply then skipped everyone). Live and dry state are now fully isolated.
const LEDGER = path.join(__dirname, '..', 'data', config.DRY_RUN ? 'honor-reissue-ledger.DRY.jsonl' : '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() {
// SAFETY PREFLIGHT: live mode with no Shopify token would make createGiftCard/disable
// silently no-op (returning a synthetic stub) while STILL emailing the customer a code —
// i.e. a code for a gift card that doesn't exist. Refuse to run live without the token.
if (!config.DRY_RUN && !config.SHOPIFY_FULFILLMENT_TOKEN) {
console.error('ABORT: --apply requested but SHOPIFY_FULFILLMENT_TOKEN is unset. Refusing to email codes for cards that would not actually be minted. Set the token, then re-run.');
process.exit(2);
}
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}`);
if (!config.DRY_RUN) await new Promise((r) => setTimeout(r, 350)); // gentle pace under Shopify's rate limit
}
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); });