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