← back to Dw Signup Fulfillment

lib/trade.js

353 lines

'use strict';
// Trade application store + moderated approval flow.
//
// Applications are appended to data/trade-applications.jsonl (append-only). On
// approve/reject we rewrite the file with the updated record (small volume, so a
// full-file rewrite is simplest and safe).
//
// APPROVE performs, all DRY_RUN-safe:
//   (a) assign to the single DW House Account (lib/reps.js — fixed assignment)
//   (b) tag the customer `trade` via Admin API (tagsAdd — appends, no clobber)
//   (c) set customer metafield custom.assigned_rep
//   (d) notify the assigned rep by email (George)
//   (e) email the applicant "you're approved"
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const reps = require('./reps');
const shopify = require('./shopify');
const email = require('./email');
const config = require('./config');

// One-click email approve/reject magic-links: HMAC over `action:id:exp` with a server
// secret, so the button in the notification email approves/rejects WITHOUT a login.
// Token is per-application AND per-action (an approve link can't be replayed as a
// reject) AND time-boxed. Format: `<exp-ms>.<sig>` where sig = HMAC(action:id:exp)
// truncated to 32 hex (128-bit). The expiry is IN the signed payload, so it can't be
// extended without the secret; the verifier reads exp from the token and rejects it
// once past. Stateless — no per-token record needed.
//
// FAIL-CLOSED (contrarian critical, 2026-07-28): if APPROVE_LINK_SECRET is unset we
// return null (mint) / false (verify) rather than signing with an empty key — a token
// signed with '' is trivially forgeable. Callers treat null/false as "magic-links off".
// EXPIRY (contrarian LOW-MED, 2026-07-28): links die after APPROVE_LINK_TTL_HOURS (48h
// default) so a forwarded/leaked, not-yet-clicked email can't be redeemed indefinitely.
function secretOk() { return typeof config.APPROVE_LINK_SECRET === 'string' && config.APPROVE_LINK_SECRET.length >= 16; }
function sign(id, action, exp) {
  return crypto.createHmac('sha256', config.APPROVE_LINK_SECRET).update(`${action}:${id}:${exp}`).digest('hex').slice(0, 32);
}
function actionToken(id, action, ttlMs) {
  if (!secretOk()) return null;
  const exp = Date.now() + (ttlMs || config.APPROVE_LINK_TTL_HOURS * 3600 * 1000);
  return `${exp}.${sign(id, action, exp)}`;
}
function verifyActionToken(id, action, token) {
  if (!token || !secretOk()) return false;
  const s = String(token);
  const dot = s.indexOf('.');
  if (dot < 1) return false;                       // malformed (old single-value format also fails → invalid)
  const exp = Number(s.slice(0, dot));
  const sig = s.slice(dot + 1);
  if (!Number.isFinite(exp) || exp < Date.now()) return false;  // malformed or EXPIRED
  const expected = sign(id, action, exp);
  const a = Buffer.from(expected), b = Buffer.from(sig);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

const DATA_DIR = path.join(__dirname, '..', 'data');
const APPS_PATH = path.join(DATA_DIR, 'trade-applications.jsonl');

function readAll() {
  try {
    return fs.readFileSync(APPS_PATH, 'utf8').split('\n').filter(Boolean).map(l => JSON.parse(l));
  } catch { return []; }
}

function rewriteAll(rows) {
  fs.mkdirSync(DATA_DIR, { recursive: true });
  fs.writeFileSync(APPS_PATH, rows.map(r => JSON.stringify(r)).join('\n') + (rows.length ? '\n' : ''));
}

function appendOne(row) {
  fs.mkdirSync(DATA_DIR, { recursive: true });
  fs.appendFileSync(APPS_PATH, JSON.stringify(row) + '\n');
}

// Create a new pending application.
function apply(body) {
  const now = new Date().toISOString();
  const app = {
    id: 'TRADE-' + now.slice(0, 10).replace(/-/g, '') + '-' + crypto.randomBytes(3).toString('hex'),
    email: (body.email || '').trim().toLowerCase(),
    business_name: body.business_name || '',
    resale_cert: body.resale_cert || '',
    phone: body.phone || '',
    // carry any extra fields the form sent, minus the ones we already captured
    extra: Object.fromEntries(Object.entries(body).filter(([k]) => !['email', 'business_name', 'resale_cert', 'phone'].includes(k))),
    // shopify customer id if the caller knows it (webhook/customer-account link);
    // otherwise null — approval logs a note that the id must be resolved at go-live.
    shopify_customer_id: body.shopify_customer_id || null,
    status: 'pending',
    created_at: now,
    decided_at: null,
    decision: null,
    assigned_rep: null,
  };
  appendOne(app);
  return app;
}

// Apply AND link in one request. This is the go-forward intake path: a public trade
// application carries NO shopify_customer_id, which used to leave approve() hard-failing
// `cannot_resolve_customer` (TK-11185 — "I filled it out and nothing happened"). Here we
// server-side find-or-create the customer FIRST (DW is on New Customer Accounts / OTP, so
// the account can't be minted through the register page — it must be minted via Admin API),
// stamp the resolved id onto the persisted application, and only then hand back so the
// caller can notify the office. Approve() can never hit cannot_resolve_customer for a new
// app that came through here.
//
// GRACEFUL DEGRADE (directive §3): if the Shopify create fails, we STILL persist the
// application (never a black hole) with shopify_customer_id:null + a link_error, and the
// applicant still sees success. A later pass can resolve those (they're queryable by
// link_status:'unlinked'). Returns { app, linkage:{ ok, id, created, via, error } }.
async function applyAndLink(body) {
  const emailAddr = (body.email || '').trim().toLowerCase();
  // Best-effort name for the customer record (New Customer Accounts shows it in-account).
  const contact = body.contact_name || body.name || '';
  const firstName = body.first_name || (contact ? String(contact).split(' ')[0] : '');
  const lastName = body.last_name || (contact ? String(contact).split(' ').slice(1).join(' ') : '');

  let linkage = { ok: false, id: null, created: false, via: null, error: null };
  try {
    const r = await shopify.findOrCreateCustomer(emailAddr, { firstName, lastName, phone: body.phone });
    if (r && r.ok && r.id) {
      linkage = { ok: true, id: r.id, created: !!r.created, via: r.via || null, error: null, dryRun: r.dryRun || false };
    } else {
      linkage.error = (r && (r.error || 'unknown')) || 'unknown';
      if (r && r.userErrors) linkage.userErrors = r.userErrors;
    }
  } catch (e) {
    linkage.error = 'exception:' + (e && e.message ? e.message : String(e));
  }

  // Stamp the resolved id (or null) BEFORE persisting so the record is born linked.
  const app = apply({ ...body, email: emailAddr, shopify_customer_id: linkage.id || null });
  // Annotate linkage on the persisted record so a later unlinked-recovery pass can find it.
  // (apply() already appended; rewrite the single row with the annotation.)
  const rows = readAll();
  const rec = rows.find(a => a.id === app.id);
  if (rec) {
    rec.link_status = linkage.ok ? 'linked' : 'unlinked';
    rec.link_via = linkage.via || null;
    rec.link_created = linkage.created || false;
    if (!linkage.ok) rec.link_error = linkage.error || 'unknown';
    rewriteAll(rows);
    Object.assign(app, { link_status: rec.link_status, link_via: rec.link_via, link_created: rec.link_created, link_error: rec.link_error });
  }
  return { app, linkage };
}

function listPending() {
  return readAll().filter(a => a.status === 'pending').sort((a, b) => a.created_at.localeCompare(b.created_at));
}

// Guarded auto-approve dedupe (Steve, 2026-09-10): true if this email already has an
// APPROVED application, so an exact-duplicate re-submit is not re-approved / re-emailed.
// Case-insensitive on the whole address (Shopify treats the local part case-insensitively
// in practice, and our intake lowercases before persist).
function emailAlreadyApproved(emailAddr) {
  const norm = String(emailAddr || '').trim().toLowerCase();
  if (!norm) return false;
  return readAll().some(a => a.status === 'approved' && String(a.email || '').trim().toLowerCase() === norm);
}

function get(id) {
  return readAll().find(a => a.id === id) || null;
}

// Approve and reject share one per-application lock inside this service process.
// This does not coordinate multiple processes writing the same JSONL. Completed
// email receipts are checkpointed; uncertain delivery needs manual reconciliation.
const decisionsInFlight = new Set();
function customerKey(value) {
  const match = String(value || '').match(/^(?:gid:\/\/shopify\/Customer\/)?([1-9]\d*)$/);
  return match ? match[1] : null;
}
function hasApiErrors(value) {
  if (!value || typeof value !== 'object') return false;
  return Object.entries(value).some(([key, child]) =>
    ((key === 'errors' || key === 'userErrors') && child &&
      (Array.isArray(child) ? child.length > 0 : true)) || hasApiErrors(child));
}
function shopifyError(result) {
  if (!result || result.ok !== true || result.status < 200 || result.status >= 300 || !Number.isFinite(result.status)) return 'shopify_http_error';
  if (result.dryRun || result.noToken) return 'shopify_simulated';
  if (!result.json || typeof result.json !== 'object' || hasApiErrors(result.json)) return 'shopify_response_error';
  return null;
}
function tagsIncludeTrade(tags) {
  const values = Array.isArray(tags) ? tags : typeof tags === 'string' ? tags.split(',') : [];
  return values.some(tag => typeof tag === 'string' && tag.trim() === 'trade_approved');
}
// Do not retain a pre-await snapshot of the entire file: new applications may
// arrive while Shopify or email is in flight.
//
// The row is a pre-await snapshot too. `app` was read before the Shopify and email
// calls, so spreading all of it over the fresh row re-asserts whatever it held at
// that moment and silently reverts anything another writer changed meanwhile -
// scripts/recover-stuck-apps.js links a row and this writes link_status back to
// 'unlinked', leaving status:'approved' with a valid customer id but a stale
// link_error. Merge only the fields a decision actually owns.
const DECISION_OWNED = ['status', 'decision', 'decided_at', 'approval_error',
  'assigned_rep', 'shopify_customer_id', 'approval_progress'];

function checkpointApproval(app) {
  const latest = readAll();
  const index = latest.findIndex(row => row.id === app.id);
  if (index < 0 || latest[index].status !== 'pending') throw new Error('application_changed');
  const patch = {};
  // Explicit key test, never a bare spread: an undefined value still spreads and
  // would erase the fresh row's field.
  for (const field of DECISION_OWNED) {
    if (app[field] !== undefined) patch[field] = app[field];
  }
  latest[index] = { ...latest[index], ...patch };
  rewriteAll(latest);
}

async function approve(id) {
  if (decisionsInFlight.has(id)) return { ok: false, error: 'approval_in_progress', id };
  const app = get(id);
  if (!app) return { ok: false, error: 'not found' };
  if (app.status !== 'pending') return { ok: false, error: `already ${app.status}` };
  // An ok:true result makes existing one-click callers display "approved". A
  // simulation therefore explicitly returns ok:false and leaves the row pending.
  if (config.DRY_RUN) return {
    ok: false, id, status: 'pending', dryRun: true, simulated: true,
    error: 'dry_run_simulated', message: 'Approval simulation only; no entitlement or email was changed.', steps: [],
  };
  decisionsInFlight.add(id);
  const steps = [];
  let phase = 'resolve_customer';
  const fail = error => {
    app.status = 'pending';
    app.decision = null;
    app.decided_at = null;
    app.approval_error = { error, step: phase, at: new Date().toISOString() };
    checkpointApproval(app);
    return { ok: false, id, status: 'pending', error, failedStep: phase, steps };
  };
  try {
    const rep = app.assigned_rep || reps.assignRep();
    let custId = customerKey(app.shopify_customer_id);
    const resolvedBy = custId ? 'application' : 'email_lookup';
    if (!custId) custId = customerKey(await shopify.findCustomerByEmail(app.email));
    if (!custId) return fail('cannot_resolve_customer');
    app.shopify_customer_id = custId;
    app.assigned_rep = { id: rep.id, name: rep.name, email: rep.email };
    const progress = app.approval_progress || { customer_id: custId, emails: {} };
    if (customerKey(progress.customer_id) !== custId) return fail('approval_customer_changed');
    app.approval_progress = progress;
    progress.emails = progress.emails || {};
    checkpointApproval(app);
    steps.push({ step: 'assign_rep', rep: app.assigned_rep }, { step: 'resolve_customer', customer: custId, via: resolvedBy });

    phase = 'tag_trade_approved';
    const tagRes = await shopify.addTags(custId, ['trade', 'trade_approved']);
    steps.push({ step: phase, customer: custId, result: summarizeShopify(tagRes) });
    const tagError = shopifyError(tagRes);
    if (tagError) return fail(tagError);
    const tagged = tagRes.json.customer || tagRes.json.data?.tagsAdd?.node;
    if (!tagged || customerKey(tagged.id) !== custId) return fail('invalid_tag_response');

    phase = 'set_metafield';
    const assignedValue = `${rep.name} <${rep.email}>`;
    const mfRes = await shopify.setCustomerMetafield(custId, {
      namespace: 'custom', key: 'assigned_rep', value: assignedValue, type: 'single_line_text_field',
    });
    steps.push({ step: phase, customer: custId, result: summarizeShopify(mfRes) });
    const mfError = shopifyError(mfRes);
    if (mfError) return fail(mfError);
    const mf = mfRes.json.metafield || mfRes.json.data?.metafieldsSet?.metafields?.find(m => m.namespace === 'custom' && m.key === 'assigned_rep');
    if (!mf || !mf.id || mf.namespace !== 'custom' || mf.key !== 'assigned_rep' || mf.value !== assignedValue ||
      (mf.owner_id != null && customerKey(mf.owner_id) !== custId)) return fail('invalid_metafield_response');

    phase = 'verify_entitlement';
    const readback = await shopify.getCustomer(custId);
    steps.push({ step: phase, customer: custId, result: summarizeShopify(readback) });
    const readError = shopifyError(readback);
    if (readError) return fail(readError);
    const customer = readback.json.customer || readback.json.data?.customer;
    if (!customer || customerKey(customer.id) !== custId || !tagsIncludeTrade(customer.tags)) return fail('entitlement_not_verified');
    progress.entitlement_verified_at = new Date().toISOString();
    checkpointApproval(app);

    for (const target of ['rep', 'applicant']) {
      phase = `email_${target}`;
      const receipt = progress.emails[target];
      if (receipt?.state === 'sent') {
        steps.push({ step: phase, skipped: true, reason: 'already_sent', sent_at: receipt.sent_at });
        continue;
      }
      if (receipt?.state === 'sending' || receipt?.state === 'unknown') return fail('email_delivery_unknown');
      const tpl = target === 'rep' ? email.repNotifyEmail({ repName: rep.name, applicant: app }) : email.tradeApprovedEmail({ applicant: app, repName: rep.name });
      const to = target === 'rep' ? rep.email : app.email;
      // Persist intent BEFORE send. A crash or transport exception cannot silently
      // lead to duplicate delivery on the next attempt.
      progress.emails[target] = { state: 'sending', attempted_at: new Date().toISOString() };
      checkpointApproval(app);
      const mail = await email.sendEmail({ to, subject: tpl.subject, html: tpl.html, source: target === 'rep' ? 'trade-rep-notify' : 'trade-approved' });
      if (!mail || mail.ok !== true || mail.dryRun || mail.noToken || !Number.isFinite(mail.status) || mail.status < 200 || mail.status >= 300) {
        const knownFailure = mail?.ok === false && Number.isFinite(mail.status) && mail.status >= 400;
        progress.emails[target].state = knownFailure ? 'failed' : 'unknown';
        return fail(knownFailure ? 'email_failed' : 'email_delivery_unknown');
      }
      progress.emails[target] = { state: 'sent', sent_at: new Date().toISOString(), status: mail.status };
      checkpointApproval(app);
      steps.push({ step: phase, to, subject: tpl.subject, dryRun: false });
    }
    app.status = 'approved';
    app.decision = 'approved';
    app.decided_at = new Date().toISOString();
    app.approval_error = null;
    checkpointApproval(app);
    return { ok: true, id, status: 'approved', rep: app.assigned_rep, steps };
  } catch (_) {
    // Do not leak transport errors (which can contain secrets or customer data).
    try { return fail('approval_exception'); }
    catch (_) { return { ok: false, id, error: 'approval_checkpoint_failed', failedStep: phase, steps }; }
  } finally {
    decisionsInFlight.delete(id);
  }
}

async function reject(id) {
  if (decisionsInFlight.has(id)) return { ok: false, error: 'approval_in_progress', id };
  const app = get(id);
  if (!app) return { ok: false, error: 'not found' };
  if (app.status !== 'pending') return { ok: false, error: `already ${app.status}` };

  decisionsInFlight.add(id);
  try {
    const tpl = email.tradeRejectedEmail();
    const mail = await email.sendEmail({ to: app.email, subject: tpl.subject, html: tpl.html, source: 'trade-rejected' });

    app.status = 'rejected';
    app.decision = 'rejected';
    app.decided_at = new Date().toISOString();
    checkpointApproval(app);

    return { ok: true, id, status: 'rejected', email: { to: app.email, subject: tpl.subject, dryRun: mail.dryRun || false } };
  } finally {
    decisionsInFlight.delete(id);
  }
}

function summarizeShopify(r) {
  if (!r) return { ok: false };
  if (r.dryRun) return { WOULD: `${r.method} ${r.url}`, payload: r.body, noToken: r.noToken || false };
  return { status: r.status, ok: r.ok };
}

module.exports = { apply, applyAndLink, listPending, emailAlreadyApproved, get, approve, reject, readAll, APPS_PATH, actionToken, verifyActionToken };