← back to Shopify Sample Shipping
trade-grant-check.mjs
144 lines
#!/usr/bin/env node
// TK-11333 — READ-ONLY: determine what the `trade` customer tag actually GRANTS on this
// LIVE store, so we can pick the grandfather VEHICLE safely.
//
// The danger: the grandfather step tags 2,663 customers so they keep FREE SAMPLE SHIPPING.
// If we tag them `trade` and `trade` ALSO unlocks trade PRICING / net cost / gated products
// / a %-off discount, we would silently hand 2,663 people trade pricing — a money leak.
//
// This script inspects every place a customer tag can grant something and reports whether
// `trade` (or a segment keyed on `trade`) is wired to anything BEYOND sample free-shipping:
// 1. Code discounts — customerSelection = DiscountCustomerSegments; which segments;
// and the discount TYPE (free-shipping vs %-off/amount-off = pricing)
// 2. Automatic discounts- (no customerSelection field on this plan, but list them anyway)
// 3. Segments — every segment whose query CONTAINS 'trade', and what consumes it
// 4. Price rules (REST) — legacy price_rules with a customer prerequisite (saved search)
// 5. Catalogs / B2B — publications/price lists gated to a customer segment
// 6. Theme references — Liquid asset text that branches on customer.tags contains 'trade'
//
// VERDICT: prints whether `trade` is SAFE to reuse as the grandfather vehicle, or whether a
// DEDICATED single-purpose tag (`sample-freeship`) must be used instead.
//
// Usage: node trade-grant-check.mjs # read-only, writes verification/trade-grant-report.json
import { query } from './query.mjs';
import { TOKEN, SHOP } from '../designerwallcoverings/scripts/lib/shopify.mjs';
import fs from 'node:fs';
const REST = `https://${SHOP}/admin/api/2024-10`;
const H = { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' };
const restGet = async p => { const r = await fetch(REST + p, { headers: H }); return { status: r.status, json: await r.json().catch(() => null) }; };
const mentionsTrade = s => typeof s === 'string' && /(^|[^a-z])trade([^a-z]|$)/i.test(s);
const report = { at: new Date().toISOString(), store: SHOP, findings: {}, tradeGrants: [], verdict: null };
// ---- 1) CODE DISCOUNTS: type + customerSelection segments -------------------------------
try {
const nodes = (await query(`{codeDiscountNodes(first:100){nodes{codeDiscount{__typename
... on DiscountCodeBasic{title status customerSelection{__typename ... on DiscountCustomerSegments{segments{id name query}} ... on DiscountCustomerAll{allCustomers}} customerGets{value{__typename}}}
... on DiscountCodeFreeShipping{title status customerSelection{__typename ... on DiscountCustomerSegments{segments{id name query}} ... on DiscountCustomerAll{allCustomers}}}
... on DiscountCodeBxgy{title status customerSelection{__typename ... on DiscountCustomerSegments{segments{id name query}}}}
}}}}`)).codeDiscountNodes.nodes;
const rows = [];
for (const n of nodes) {
const d = n.codeDiscount; if (!d) continue;
const sel = d.customerSelection;
const segs = sel?.segments || [];
const isPricing = d.__typename === 'DiscountCodeBasic' || d.__typename === 'DiscountCodeBxgy';
const segTrade = segs.filter(s => mentionsTrade(s.name) || mentionsTrade(s.query));
rows.push({ title: d.title, type: d.__typename, status: d.status, selection: sel?.__typename, segments: segs.map(s => s.name), tradeSegments: segTrade.map(s => s.name), grantsPricing: isPricing });
if (segTrade.length) report.tradeGrants.push({ where: 'code-discount', title: d.title, type: d.__typename, grantsPricing: isPricing, via: segTrade.map(s => s.name) });
}
report.findings.codeDiscounts = rows;
} catch (e) { report.findings.codeDiscounts = { error: e.message }; }
// ---- 2) AUTOMATIC DISCOUNTS -------------------------------------------------------------
try {
const nodes = (await query(`{automaticDiscountNodes(first:100){nodes{automaticDiscount{__typename
... on DiscountAutomaticBasic{title status}
... on DiscountAutomaticFreeShipping{title status}
... on DiscountAutomaticBxgy{title status}}}}}`)).automaticDiscountNodes.nodes;
report.findings.automaticDiscounts = nodes.map(n => ({ title: n.automaticDiscount?.title, type: n.automaticDiscount?.__typename, status: n.automaticDiscount?.status }));
} catch (e) { report.findings.automaticDiscounts = { error: e.message }; }
// ---- 3) SEGMENTS keyed on trade + what consumes them ------------------------------------
try {
const segs = (await query(`{segments(first:150){nodes{id name query}}}`)).segments.nodes;
const tradeSegs = segs.filter(s => mentionsTrade(s.name) || mentionsTrade(s.query));
report.findings.tradeSegments = tradeSegs.map(s => ({ name: s.name, query: s.query }));
} catch (e) { report.findings.tradeSegments = { error: e.message }; }
// ---- 4) PRICE RULES (REST) with a customer prerequisite --------------------------------
try {
const r = await restGet('/price_rules.json?limit=250');
const prs = (r.json?.price_rules || []).map(p => ({
title: p.title, value_type: p.value_type, value: p.value, target_type: p.target_type,
prerequisite_customer_ids: (p.prerequisite_customer_ids || []).length,
customer_selection: p.customer_selection, saved_search: p.prerequisite_saved_search_ids || [],
}));
// We cannot always read a saved_search's tag filter via REST, but a price rule with
// customer_selection='prerequisite' + a saved_search is a candidate tag-gated PRICING grant.
report.findings.priceRules = { count: prs.length, tagGatedCandidates: prs.filter(p => p.customer_selection === 'prerequisite') };
} catch (e) { report.findings.priceRules = { error: e.message }; }
// ---- 5) CATALOGS / B2B price lists gated to a segment ----------------------------------
try {
const cats = (await query(`{catalogs(first:50,type:COMPANY_LOCATION){nodes{id title status ... on CompanyLocationCatalog{companyLocationsCount{count}}}}}`).catch(() => null));
report.findings.catalogs = cats?.catalogs?.nodes || 'none-or-not-supported';
} catch (e) { report.findings.catalogs = { error: e.message }; }
// ---- 6) THEME LIQUID references to customer.tags 'trade' -------------------------------
try {
const themesR = await restGet('/themes.json');
const main = (themesR.json?.themes || []).find(t => t.role === 'main');
const hits = [];
if (main) {
const assetsR = await restGet(`/themes/${main.id}/assets.json`);
const keys = (assetsR.json?.assets || []).map(a => a.key).filter(k => /\.(liquid|js)$/i.test(k));
// Scan a bounded set of likely-relevant assets (cart/product/pricing/customer) to stay fast.
const scan = keys.filter(k => /(cart|product|price|customer|trade|snippet|template|section|main|theme)/i.test(k)).slice(0, 120);
for (const key of scan) {
const a = await restGet(`/themes/${main.id}/assets.json?asset[key]=${encodeURIComponent(key)}`);
const v = a.json?.asset?.value || '';
if (/customer\.tags[\s\S]{0,80}trade/i.test(v) || /['"]trade['"][\s\S]{0,80}customer\.tags/i.test(v) || /contains\s+['"]trade['"]/i.test(v)) {
const idx = v.search(/trade/i);
hits.push({ key, snippet: v.slice(Math.max(0, idx - 90), idx + 60).replace(/\s+/g, ' ') });
}
}
report.findings.themeMain = main.name;
}
report.findings.themeTradeTagRefs = hits;
} catch (e) { report.findings.themeTradeTagRefs = { error: e.message }; }
// ---- VERDICT ---------------------------------------------------------------------------
const pricingGrants = report.tradeGrants.filter(g => g.grantsPricing);
const themeGrants = (report.findings.themeTradeTagRefs || []).filter(h => h.key);
const anyGrant = pricingGrants.length > 0 || themeGrants.length > 0;
// Regardless of what we find, the single-purpose vehicle is strictly safer for a 2,663-customer
// grandfather whose ONLY intent is sample free-shipping. We only "green-light reuse of `trade`"
// if trade demonstrably grants NOTHING beyond sample shipping AND Steve prefers one tag.
report.verdict = {
tradeGrantsBeyondSampleShipping: anyGrant,
pricingGrantsViaTrade: pricingGrants,
themeBranchesOnTrade: themeGrants.map(h => h.key),
recommendedVehicle: 'sample-freeship',
rationale: anyGrant
? 'trade is wired to pricing/content beyond sample-shipping — mass-tagging 2,663 grandfathered customers `trade` would leak those perks. MUST use a dedicated `sample-freeship` tag; the DW Trade/Designers segment ORs the real trade tags AND `sample-freeship`.'
: 'No pricing/content grant found wired to `trade` today, BUT a dedicated single-purpose `sample-freeship` tag is still the safe vehicle: it is additive, cannot accidentally confer any FUTURE trade perk that later keys on `trade`, and the segment ORs it alongside the real trade tags so grandfathered designers get free sample shipping and nothing else.',
vehicleForSegment: "segment query ORs all real trade tags (memo §4) PLUS customer_tags CONTAINS 'sample-freeship'",
};
fs.writeFileSync(new URL('./verification/trade-grant-report.json', import.meta.url), JSON.stringify(report, null, 2) + '\n');
console.log('=== TRADE-GRANT CHECK (read-only) ===');
console.log('code discounts:', Array.isArray(report.findings.codeDiscounts) ? report.findings.codeDiscounts.length : report.findings.codeDiscounts);
for (const r of (Array.isArray(report.findings.codeDiscounts) ? report.findings.codeDiscounts : [])) {
console.log(` • ${r.title} [${r.type} ${r.status}] sel=${r.selection} pricing=${r.grantsPricing} tradeSeg=${r.tradeSegments.join('|') || '—'}`);
}
console.log('trade-keyed segments:', JSON.stringify(report.findings.tradeSegments));
console.log('price rules tag-gated candidates:', report.findings.priceRules?.tagGatedCandidates?.length ?? report.findings.priceRules);
console.log('theme branches on trade tag:', JSON.stringify(report.findings.themeTradeTagRefs));
console.log('\n>>> trade grants beyond sample shipping?', report.verdict.tradeGrantsBeyondSampleShipping);
console.log('>>> RECOMMENDED VEHICLE:', report.verdict.recommendedVehicle);
console.log('>>> rationale:', report.verdict.rationale);
console.log('\nfull report -> verification/trade-grant-report.json');