← back to Tk 10965 Zero Price Analysis

tk10964-canary-guard/rollback/TK-10974-preinstall/check.mjs

42 lines

#!/usr/bin/env node
// zero-price-orderable-canary — sibling of discontinued-orderable-canary (same root cause:
// UI hides the buy button but the /cart/add endpoint still accepts the variant).
// READ-ONLY: flags ACTIVE products with a NON-Sample variant priced $0 that is ORDERABLE
// (availableForSale OR inventoryPolicy=CONTINUE) — a customer can check out at $0 (revenue loss)
// or a quote-only item can be bought without a quote. Emits data/latest.json PASS/WARN/FAIL for
// fleet-health-rollup. $0 (live Shopify reads unmetered). Ref incident: pending-approval/_approved/
// 2026-07-29-quote-only-zero-price-checkout-exposure.md
import fs from 'node:fs';
import path from 'node:path';
const HERE=path.dirname(new URL(import.meta.url).pathname);
const OUT=path.join(HERE,'data','latest.json');
const env=fs.readFileSync('/Users/macstudio3/Projects/secrets-manager/.env','utf8');
const val=k=>(env.match(new RegExp('^'+k+'=(.*)$','m'))||[])[1]?.trim();
const DOM=val('SHOPIFY_STORE_DOMAIN'), TOK=val('SHOPIFY_ADMIN_TOKEN');
const API=`https://${DOM}/admin/api/2024-10/graphql.json`;
async function gql(q,v){for(let a=0;a<6;a++){const r=await fetch(API,{method:'POST',headers:{'X-Shopify-Access-Token':TOK,'Content-Type':'application/json'},body:JSON.stringify({query:q,variables:v})});const j=await r.json();if(j.errors){if(JSON.stringify(j.errors).includes('THROTTLED')){await new Promise(s=>setTimeout(s,1800*(a+1)));continue;}throw new Error(JSON.stringify(j.errors));}return j.data;}throw new Error('retries');}
// Search quote-only tagged AND (separately) sample-priced tags; also catch any active with a $0 real variant.
const SEARCH=`status:active AND (tag:'quote-only' OR tag:'Quote Only' OR tag:'quote_only' OR tag:'Quote-Only')`;
// availableForSale===true is Shopify's authoritative "this variant is purchasable" signal.
// (Do NOT OR-in inventoryPolicy=CONTINUE — that overcounts non-buyable rows.)
function isBad(p){
  return p.variants.nodes
    .filter(v=>!/sample/i.test(v.title||''))
    .some(v=> Number(v.price)===0 && v.availableForSale===true);
}
async function run(){
  let after=null,all=[],pages=0;
  do{ const d=await gql(`query($q:String!,$after:String){products(first:100,query:$q,after:$after){pageInfo{hasNextPage endCursor} nodes{id title variants(first:20){nodes{title price availableForSale inventoryPolicy}}}}}`,{q:SEARCH,after});
    all.push(...d.products.nodes); after=d.products.pageInfo.hasNextPage?d.products.pageInfo.endCursor:null; pages++;
  }while(after&&pages<40);
  const bad=all.filter(isBad);
  const verdict = bad.length===0 ? 'PASS' : (bad.length<=5 ? 'WARN' : 'FAIL');
  const out={ skill:'zero-price-orderable-canary', verdict, status:verdict, ts:new Date().toISOString(),
    quote_only_active: all.length, zero_price_orderable: bad.length,
    detail: verdict==='PASS'?'no quote-only/active product has a $0 orderable non-sample variant':`${bad.length} products have a $0 ORDERABLE non-sample variant (checkout at $0 / quote bypass)`,
    sample_ids: bad.slice(0,10).map(p=>({id:p.id.split('/').pop(),title:p.title})) };
  fs.mkdirSync(path.dirname(OUT),{recursive:true}); fs.writeFileSync(OUT,JSON.stringify(out,null,2));
  console.log(`${verdict}: ${bad.length} zero-price-orderable of ${all.length} quote-only active`);
}
run().catch(e=>{const out={skill:'zero-price-orderable-canary',verdict:'WARN',status:'WARN',ts:new Date().toISOString(),detail:'canary error: '+e.message};fs.mkdirSync(path.dirname(OUT),{recursive:true});fs.writeFileSync(OUT,JSON.stringify(out,null,2));console.error('ERR',e.message);process.exit(0);});