← back to CelebritySignatures

scripts/reconcile-wear-orders.mjs

198 lines

#!/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, mkdir } from 'node:fs/promises';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { join } from 'node:path';
import { spawn } from 'node:child_process';
import { createHash } from 'node:crypto';

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 WEAR_STRIPE_KEY resolver exactly — wear has its OWN
// live switch (WEAR_STRIPE_LIVE_ENABLED), deliberately separate from
// STRIPE_LIVE_ENABLED (which murals/downloads already use), so wear can go
// fully live everywhere else while Steve still places one test order through
// wear specifically before flipping this switch too (TK-10286, 2026-09-09).
function resolveStripeKey() {
  const liveEnabled = envVal('WEAR_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];
}
// Mirrors server.js's wearOrderItems() — orders placed before the cart
// feature (2026-09-08) store one item's fields directly on the order;
// the cart shape stores an items[] array instead.
function wearOrderItems(order) {
  if (Array.isArray(order.items)) return order.items;
  if (!order.qid) return [];
  return [{ qid: order.qid, signature_name: order.signature_name, garment: order.garment, garment_label: order.garment_label,
    color: order.color, color_label: order.color_label, size: order.size, placement: order.placement }];
}
// Mirrors server.js's wearLuminance()/safeInkHex()/recolorSignatureInk()
// exactly (see that file for the full explanation) — a dark-ink signature on
// a dark garment needs recoloring or it prints near-invisible (Printify
// prints a design's own pixel colors as-is). Recolor ONLY when there's a
// reason: the buyer picked an ink color, or the cloth is dark (white,
// matching the on-shirt preview's inkHexFor()) — otherwise the ORIGINAL art
// ships untouched. Fails open: any recolor error falls back to the original
// image rather than blocking reconciliation.
const wearLuminance = hex => {
  const n = parseInt(hex.slice(1), 16), r = (n>>16)&255, g = (n>>8)&255, b = n&255;
  return (0.299*r + 0.587*g + 0.114*b) / 255;
};
// Sanitize a stored inkColor before it can reach ImageMagick's -fill; only a
// literal #rrggbb is allowed, anything else is treated as "no choice made".
const safeInkHex = v => (typeof v === 'string' && /^#[0-9a-fA-F]{6}$/.test(v)) ? v.toLowerCase() : null;
const RECOLOR_DIR = join(ROOT, 'public', 'assets', 'recolored-signatures');
const WEAR_SITE_ORIGIN = 'https://celebsignatures.com';
async function recolorSignatureInk(imageUrl, hex) {
  const ink = safeInkHex(hex) || '#f5f3ee';   // fall back to the old light ink
  try {
    const hash = createHash('sha256').update(imageUrl + '|' + ink).digest('hex').slice(0, 24);
    const outPath = join(RECOLOR_DIR, `${hash}.png`);
    // FULLY QUALIFIED — flows into the Printify order payload as images[0].src,
    // which Printify fetches from the public internet (caught via a2a from
    // codex-run-10286, 2026-09-09).
    const outUrl = `${WEAR_SITE_ORIGIN}/assets/recolored-signatures/${hash}.png`;
    try { await readFile(outPath); return outUrl; } catch {}
    const src = await fetch(imageUrl);
    if (!src.ok) throw new Error(`fetch ${src.status}`);
    const buf = Buffer.from(await src.arrayBuffer());
    const out = await new Promise((resolve, reject) => {
      // 'convert' not 'magick': the Kamatera prod box only has ImageMagick 6
      // (convert), not the IMv7 'magick' unified CLI — verified via SSH.
      const proc = spawn('convert', ['-background', 'none', '-', '-channel', 'RGB', '-fill', ink, '-colorize', '100%', 'png:-']);
      const chunks = []; let errText = '';
      const timer = setTimeout(() => { proc.kill(); reject(new Error('magick timeout')); }, 8000);
      proc.stdout.on('data', c => chunks.push(c));
      proc.stderr.on('data', c => errText += c);
      proc.on('error', e => { clearTimeout(timer); reject(e); });
      proc.on('close', code => { clearTimeout(timer); code === 0 ? resolve(Buffer.concat(chunks)) : reject(new Error(errText || `magick exit ${code}`)); });
      proc.stdin.end(buf);
    });
    await mkdir(RECOLOR_DIR, { recursive: true });
    await writeFile(outPath, out);
    return outUrl;
  } catch (e) {
    console.error('recolorSignatureInk failed, using original image:', e.message);
    return imageUrl;
  }
}

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}: 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) {
      if (!sigCache) sigCache = await mergedSignatures();
      const draftTpl = await load('wear-templates.json', { garments: [] });
      const items = wearOrderItems(order);
      for (let i = 0; i < items.length; i++) {
        const it = items[i];
        const compositeId = `${order.id}.${i}`;
        if (alreadyDraftedIds.has(compositeId)) continue;
        const sig = sigCache.find(x => qidOf(x) === it.qid);
        let designUrl = sig ? sig.signature_image_url : null;
        if (designUrl) {
          const g = (draftTpl.garments || []).find(x => x.id === it.garment);
          const c = g && (g.colors || []).find(x => x.id === it.color);
          const darkCloth = !!(c && wearLuminance(c.hex) < 0.4);
          // Recolor ONLY when there's a reason: the buyer picked a color, or
          // the cloth is dark (a dark signature would vanish → white).
          // Otherwise keep the ORIGINAL art in its true source colors.
          const ink = safeInkHex(it.inkColor) || (darkCloth ? '#ffffff' : null);
          if (ink) designUrl = await recolorSignatureInk(designUrl, ink);
        }
        const draft = { draftedAt: new Date().toISOString(), orderId: compositeId, status: 'DRAFT_UNSENT', provider,
          recipient_email: order.email, recipient: order.recipient, garment: it.garment, color: it.color, size: it.size,
          placement: it.placement, signature_name: it.signature_name,
          design_image_url: designUrl,
          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(compositeId);
      }
    }
    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); });