← 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); });