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