← back to Dw Signup Fulfillment

lib/shopify.js

300 lines

'use strict';
// Thin Shopify Admin API client. Reads SHOPIFY_FULFILLMENT_TOKEN from config.
//
// SAFETY: when config.DRY_RUN is true, every WRITE verb (POST/PUT/DELETE) is
// short-circuited — the intended method, URL and payload are logged and a
// synthetic ok response is returned. No live call is made. Only GET is allowed
// to hit the network in DRY_RUN (reads are harmless), and even that requires a
// real token — otherwise it too is stubbed.
const config = require('./config');

const WRITE_VERBS = new Set(['POST', 'PUT', 'DELETE']);

function base() {
  return `https://${config.SHOP_DOMAIN}/admin/api/${config.SHOPIFY_API_VERSION}`;
}

function log(...a) { console.log('[shopify]', ...a); }

// Core request. path is relative to /admin/api/<ver> (e.g. '/gift_cards.json').
async function request(method, path, body) {
  const url = base() + path;
  const isWrite = WRITE_VERBS.has(method.toUpperCase());

  if (config.DRY_RUN && isWrite) {
    log(`DRY_RUN — WOULD ${method} ${url}`);
    if (body !== undefined) log('DRY_RUN — payload:', JSON.stringify(body, null, 2));
    return { ok: true, dryRun: true, method, url, body, status: 0, json: synthetic(path, body) };
  }

  if (!config.SHOPIFY_FULFILLMENT_TOKEN) {
    // No token yet (pre go-live). Never throw — return a clearly-marked stub so
    // callers and the selftest keep working. This is the expected state today.
    log(`NO TOKEN — WOULD ${method} ${url} (SHOPIFY_FULFILLMENT_TOKEN unset)`);
    if (body !== undefined) log('NO TOKEN — payload:', JSON.stringify(body, null, 2));
    return { ok: true, dryRun: true, noToken: true, method, url, body, status: 0, json: synthetic(path, body) };
  }

  // Shopify Admin REST allows ~2 req/s (leaky bucket). A burst run (e.g. honoring 51 cards
  // = ~150 calls) will hit 429; respect Retry-After and retry so the batch doesn't fail
  // intermittently. Up to 5 attempts; non-429 errors return as-is for the caller to handle.
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
  let res, json = null;
  for (let attempt = 0; attempt < 5; attempt++) {
    res = await fetch(url, {
      method,
      headers: {
        'X-Shopify-Access-Token': config.SHOPIFY_FULFILLMENT_TOKEN,
        'Content-Type': 'application/json',
        'Accept': 'application/json',
      },
      body: body !== undefined ? JSON.stringify(body) : undefined,
    });
    if (res.status !== 429) break;
    const wait = Math.max(1000, Math.round((parseFloat(res.headers.get('Retry-After')) || 2) * 1000));
    log(`429 rate-limited on ${method} ${path} — retrying in ${wait}ms (attempt ${attempt + 1}/5)`);
    await sleep(wait);
  }
  try { json = await res.json(); } catch { json = null; }
  return { ok: res.ok, status: res.status, method, url, json };
}

// Build a plausible synthetic response so dry-run callers can read back an id/code.
function synthetic(path, body) {
  const randId = (base) => base + Math.floor(Math.random() * 1e6);
  if (path.startsWith('/gift_cards')) {
    const code = 'DRYRUN' + Math.random().toString(36).slice(2, 10).toUpperCase();
    return { gift_card: { id: randId(900000000), code, masked_code: '••••' + code.slice(-4), balance: body?.gift_card?.initial_value, initial_value: body?.gift_card?.initial_value } };
  }
  if (path.startsWith('/price_rules') && !path.includes('discount_codes')) {
    return { price_rule: { id: randId(800000000) } };
  }
  if (path.includes('discount_codes')) {
    const code = body?.discount_code?.code || ('DRYRUN' + Math.random().toString(36).slice(2, 8).toUpperCase());
    return { discount_code: { id: randId(700000000), code } };
  }
  if (path.startsWith('/customers') && body?.customer) {
    return { customer: { id: body.customer.id || randId(600000000), ...body.customer } };
  }
  if (path.startsWith('/webhooks')) {
    return { webhook: { id: randId(500000000), ...(body?.webhook || {}) } };
  }
  return {};
}

// ---- High-level helpers ----

// Create a Shopify gift card with a fixed initial_value.
async function createGiftCard({ value, note, currency, code }) {
  const gift_card = { initial_value: String(value), note: note || 'DW retail free-samples', currency: currency || config.CURRENCY };
  // Shopify auto-generates a long random code unless we supply our own. A custom code
  // must be >= 8 chars; we pass a short branded DW###### so the email is easy to read/type.
  if (code) gift_card.code = code;
  return request('POST', '/gift_cards.json', { gift_card });
}

// Disable (void) a gift card by id — POST /gift_cards/<id>/disable.json. This is
// IRREVERSIBLE in Shopify (a disabled card can never be re-enabled) and zeroes the
// card's redeemable balance, which is exactly what clears the phantom liability of
// the 53 orphaned free-sample cards whose codes were never persisted. Being a POST,
// it short-circuits safely under DRY_RUN via request().
async function disableGiftCard(id) {
  return request('POST', `/gift_cards/${id}/disable.json`, { gift_card: { id } });
}

// Add tags to a customer (tagsAdd equivalent — REST needs the merged tag string,
// so the caller passes the already-merged value; addTags() merges for you).
async function updateCustomer(customerId, fields) {
  return request('PUT', `/customers/${customerId}.json`, { customer: { id: customerId, ...fields } });
}

// Read a customer's current tags so we can append rather than clobber.
async function getCustomer(customerId) {
  return request('GET', `/customers/${customerId}.json`, undefined);
}

// Resolve a Shopify customer id from an email (read_customers scope). Used at trade
// approval when the application came from the public form (no shopify_customer_id).
// Returns the id, or null if no customer with that email exists / can't be read.
async function findCustomerByEmail(emailAddr) {
  const e = (emailAddr || '').trim().toLowerCase();
  if (!e) return null;
  const r = await request('GET', `/customers/search.json?query=${encodeURIComponent('email:' + e)}`, undefined);
  const c = r && r.json && Array.isArray(r.json.customers) ? r.json.customers[0] : null;
  return c && c.id ? c.id : null;
}

// Create a Shopify customer via the GraphQL Admin API (customerCreate mutation).
// DRY_RUN-safe: graphql() short-circuits mutations under DRY_RUN / no-token and
// returns the synthetic envelope below, so a dry run "creates" a plausible id.
//
// DW runs NEW CUSTOMER ACCOUNTS (passwordless OTP, Shopify-hosted) — VERIFIED live
// 2026-09-03: shop.customerAccountsV2.customerAccountsVersion = NEW_CUSTOMER_ACCOUNTS.
// You therefore CANNOT carry a token through Shopify's register page into the
// customers/create webhook; the account must be minted server-side here so a public
// trade application (no store account) is immediately linkable + approvable.
//
// Returns a NUMERIC id (e.g. 600000123) to stay consistent with findCustomerByEmail()
// + the REST addTags()/setCustomerMetafield() helpers, which all key on numeric ids.
// The GraphQL customer.id is a GID (gid://shopify/Customer/123) — we return its tail.
// `deps` is a testability seam ONLY (default = the module internals). createCustomer
// calls the module-local graphql()/findCustomerByEmail() directly, which the selftest
// can't monkeypatch through the exports; injecting them here lets the taken/phone-retry
// branches be unit-tested without a live Shopify call. Production callers pass nothing.
async function createCustomer(emailAddr, fields = {}, deps = {}) {
  const gql = deps.graphql || graphql;
  const findByEmail = deps.findCustomerByEmail || findCustomerByEmail;
  const e = (emailAddr || '').trim().toLowerCase();
  if (!e) return { ok: false, id: null, error: 'email_required' };

  const input = { email: e };
  if (fields.firstName) input.firstName = String(fields.firstName).trim();
  if (fields.lastName) input.lastName = String(fields.lastName).trim();
  if (fields.phone) input.phone = String(fields.phone).trim();

  const query = `mutation createCust($input: CustomerInput!) {
    customerCreate(input: $input) {
      customer { id email }
      userErrors { field message }
    }
  }`;
  const synthetic = () => ({
    customerCreate: {
      customer: { id: 'gid://shopify/Customer/' + (600000000 + Math.floor(Math.random() * 1e6)), email: e },
      userErrors: [],
    },
  });

  const r = await gql(query, { input }, { synthetic });
  const payload = r && r.json && r.json.data ? r.json.data.customerCreate : null;
  const userErrors = (payload && payload.userErrors) || [];

  // A phone/format userError shouldn't sink the whole create — retry once without the
  // phone (email is the only field that actually matters for linkage/OTP login).
  if (userErrors.length && input.phone && userErrors.some(u => /phone/i.test(u.field || '') || /phone/i.test(u.message || ''))) {
    delete input.phone;
    return createCustomer(e, { firstName: input.firstName, lastName: input.lastName }, deps);
  }

  // "Email has already been taken" — a customer already exists (race vs our pre-check,
  // or a webhook created it meanwhile). Re-resolve by email and reuse that id.
  if (userErrors.some(u => /taken|already/i.test(u.message || ''))) {
    const existing = await findByEmail(e);
    if (existing) return { ok: true, id: existing, created: false, via: 'existing_taken', dryRun: r.dryRun || false };
    return { ok: false, id: null, error: 'email_taken_unresolvable', userErrors };
  }

  if (userErrors.length) return { ok: false, id: null, error: 'user_errors', userErrors };

  const gid = payload && payload.customer && payload.customer.id ? String(payload.customer.id) : '';
  const numericId = gid ? gid.split('/').pop() : null;
  if (!numericId) return { ok: false, id: null, error: 'no_id_returned', raw: r && r.json };
  return { ok: true, id: numericId, created: true, via: 'created', dryRun: r.dryRun || false };
}

// Resolve a customer id for an email, creating the account if none exists. Linkage is
// ALWAYS by the resolved customer id — never by later email-guessing. Returns
// { ok, id, created, via } or { ok:false, id:null, error } (caller degrades gracefully).
async function findOrCreateCustomer(emailAddr, fields = {}) {
  const e = (emailAddr || '').trim().toLowerCase();
  if (!e) return { ok: false, id: null, error: 'email_required' };
  const existing = await findCustomerByEmail(e);
  if (existing) return { ok: true, id: existing, created: false, via: 'existing' };
  return createCustomer(e, fields);
}

// Append a tag to a customer without dropping existing tags.
async function addTags(customerId, newTags) {
  const wanted = (Array.isArray(newTags) ? newTags : [newTags]).map(t => t.trim()).filter(Boolean);
  let existing = [];
  const cur = await getCustomer(customerId);
  // Never replace tags after a failed/malformed merge read. Approval's final
  // readback cannot recover unrelated tags that an unsafe merge already erased.
  if (!cur?.dryRun) {
    const customer = cur?.json?.customer;
    if (cur?.ok !== true || !Number.isFinite(cur.status) || cur.status < 200 || cur.status >= 300 ||
      !customer || String(customer.id) !== String(customerId).replace('gid://shopify/Customer/', '') ||
      typeof customer.tags !== 'string' || cur.json.errors) {
      return { ok: false, status: cur?.status || 0, error: 'customer_tags_read_failed', json: cur?.json || null };
    }
  }
  if (cur?.json?.customer?.tags) existing = cur.json.customer.tags.split(',').map(t => t.trim()).filter(Boolean);
  const merged = Array.from(new Set([...existing, ...wanted])).join(', ');
  return updateCustomer(customerId, { tags: merged });
}

// Read one customer metafield value (namespace.key), or null. Used for the
// welcome-gift idempotency flag. GET → allowed in DRY_RUN (with a real token).
async function getCustomerMetafield(customerId, namespace, key) {
  const r = await request('GET', `/customers/${customerId}/metafields.json?namespace=${encodeURIComponent(namespace)}`, undefined);
  const mfs = r && r.json && Array.isArray(r.json.metafields) ? r.json.metafields : [];
  const m = mfs.find(x => x.key === key && x.namespace === namespace);
  return m ? m.value : null;
}

// Set a customer metafield (e.g. custom.assigned_rep).
async function setCustomerMetafield(customerId, { namespace, key, value, type }) {
  return request('POST', `/customers/${customerId}/metafields.json`, {
    metafield: { namespace, key, value: String(value), type: type || 'single_line_text_field' },
  });
}

// Register a webhook (used at go-live, from DEPLOY.md).
async function createWebhook({ topic, address, format }) {
  return request('POST', '/webhooks.json', { webhook: { topic, address, format: format || 'json' } });
}

// ---- GraphQL Admin API ----
//
// A GraphQL POST to /graphql.json. A GraphQL *mutation* is a WRITE, so in DRY_RUN
// (or with no token) we short-circuit it exactly like a REST write: log the WOULD-
// call + the query/variables and return a synthetic ok envelope so callers can read
// back a plausible id. A GraphQL *query* (read) is allowed to hit the network in
// DRY_RUN, but only if a real token exists — otherwise it too is stubbed.
//
//   graphql(query, variables, { synthetic })
//     - `synthetic(query, variables)` (optional) builds the fake `data` object
//       returned in DRY_RUN so downstream code sees a realistic shape.
function isMutation(query) {
  return /^\s*mutation\b/m.test(query || '') || /(^|\})\s*mutation\b/.test(query || '');
}

async function graphql(query, variables, opts = {}) {
  const url = `${base()}/graphql.json`;
  const write = isMutation(query);
  const syn = typeof opts.synthetic === 'function' ? opts.synthetic(query, variables) : {};

  if (config.DRY_RUN && write) {
    log(`DRY_RUN — WOULD POST ${url} (GraphQL mutation)`);
    log('DRY_RUN — query:', query.replace(/\s+/g, ' ').trim());
    log('DRY_RUN — variables:', JSON.stringify(variables, null, 2));
    return { ok: true, dryRun: true, method: 'POST', url, query, variables, status: 0, json: { data: syn } };
  }

  if (!config.SHOPIFY_FULFILLMENT_TOKEN) {
    log(`NO TOKEN — WOULD POST ${url} (GraphQL ${write ? 'mutation' : 'query'}; SHOPIFY_FULFILLMENT_TOKEN unset)`);
    log('NO TOKEN — query:', query.replace(/\s+/g, ' ').trim());
    log('NO TOKEN — variables:', JSON.stringify(variables, null, 2));
    return { ok: true, dryRun: true, noToken: true, method: 'POST', url, query, variables, status: 0, json: { data: syn } };
  }

  const res = await fetch(url, {
    method: 'POST',
    headers: {
      'X-Shopify-Access-Token': config.SHOPIFY_FULFILLMENT_TOKEN,
      'Content-Type': 'application/json',
      'Accept': 'application/json',
    },
    body: JSON.stringify({ query, variables: variables || {} }),
  });
  let json = null;
  try { json = await res.json(); } catch { json = null; }
  return { ok: res.ok, status: res.status, method: 'POST', url, json };
}

module.exports = {
  request, graphql, createGiftCard, disableGiftCard, updateCustomer, getCustomer, getCustomerMetafield, findCustomerByEmail,
  createCustomer, findOrCreateCustomer, addTags,
  setCustomerMetafield, createWebhook, base,
};