← back to Dw Signup Fulfillment

lib/trade.js

195 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;
}

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

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

// APPROVE — runs the full fan-out, all writes dry-run-safe via lib/shopify.
async function approve(id) {
  const rows = readAll();
  const app = rows.find(a => a.id === id);
  if (!app) return { ok: false, error: 'not found' };
  if (app.status !== 'pending') return { ok: false, error: `already ${app.status}` };

  const steps = [];

  // (a) assign to the DW House Account (fixed — not round-robin)
  const rep = reps.assignRep();
  steps.push({ step: 'assign_rep', rep: { id: rep.id, name: rep.name, email: rep.email } });

  // Resolve the Shopify customer id. Public-form applications carry no id, so look it
  // up by email (read_customers scope). NEVER write against a placeholder id — if we
  // can't resolve a real customer, HARD-FAIL the approve with a clear admin message so
  // the customer is never left silently untagged (which would still charge them for
  // samples). (Closes the original memo §5 "customer-id-by-email" TODO + Cody #4.)
  let custId = app.shopify_customer_id;
  let resolvedBy = custId ? 'application' : null;
  if (!custId) {
    custId = await shopify.findCustomerByEmail(app.email);
    if (custId) resolvedBy = 'email_lookup';
  }
  if (!custId) {
    return {
      ok: false,
      error: 'cannot_resolve_customer',
      message: `No Shopify customer found for ${app.email}. The applicant must have a store account (signed up) before trade approval. Not writing anything.`,
      email: app.email,
    };
  }
  steps.push({ step: 'resolve_customer', customer: custId, via: resolvedBy });

  // (b) tag `trade`
  const tagRes = await shopify.addTags(custId, ['trade']);
  steps.push({ step: 'tag_trade', customer: custId, result: summarizeShopify(tagRes) });

  // (c) metafield custom.assigned_rep
  const mfRes = await shopify.setCustomerMetafield(custId, {
    namespace: 'custom', key: 'assigned_rep', value: `${rep.name} <${rep.email}>`, type: 'single_line_text_field',
  });
  steps.push({ step: 'set_metafield', customer: custId, result: summarizeShopify(mfRes) });

  // (d) notify rep
  const repTpl = email.repNotifyEmail({ repName: rep.name, applicant: app });
  const repMail = await email.sendEmail({ to: rep.email, subject: repTpl.subject, html: repTpl.html, source: 'trade-rep-notify' });
  steps.push({ step: 'email_rep', to: rep.email, subject: repTpl.subject, dryRun: repMail.dryRun || false });

  // (e) email applicant approved
  const appTpl = email.tradeApprovedEmail({ applicant: app, repName: rep.name });
  const appMail = await email.sendEmail({ to: app.email, subject: appTpl.subject, html: appTpl.html, source: 'trade-approved' });
  steps.push({ step: 'email_applicant', to: app.email, subject: appTpl.subject, dryRun: appMail.dryRun || false });

  app.status = 'approved';
  app.decision = 'approved';
  app.decided_at = new Date().toISOString();
  app.shopify_customer_id = custId; // persist the resolved id for audit
  app.assigned_rep = { id: rep.id, name: rep.name, email: rep.email };
  rewriteAll(rows);

  return { ok: true, id, status: 'approved', rep: app.assigned_rep, steps };
}

async function reject(id) {
  const rows = readAll();
  const app = rows.find(a => a.id === id);
  if (!app) return { ok: false, error: 'not found' };
  if (app.status !== 'pending') return { ok: false, error: `already ${app.status}` };

  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();
  rewriteAll(rows);

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

function summarizeShopify(r) {
  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, listPending, get, approve, reject, readAll, APPS_PATH, actionToken, verifyActionToken };