[object Object]

← back to Fineartamerica Price Sync

FAA price-sync: SKU->FAA cost->cost/0.65/0.85 Shopify reprice + loss-guard canary

ac5c24eda4c9a0f374c4959f3aebf7ff67f6ae24 · 2026-07-16 08:41:17 -0700 · Steve Abrams

Files touched

Diff

commit ac5c24eda4c9a0f374c4959f3aebf7ff67f6ae24
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Jul 16 08:41:17 2026 -0700

    FAA price-sync: SKU->FAA cost->cost/0.65/0.85 Shopify reprice + loss-guard canary
---
 .gitignore       |  12 ++++++
 README.md        |  66 ++++++++++++++++++++++++++++++++
 bin/faa-fetch.js | 112 +++++++++++++++++++++++++++++++++++++++++++++++++++++++
 canary.js        |  66 ++++++++++++++++++++++++++++++++
 lib/faa-cost.js  |  85 +++++++++++++++++++++++++++++++++++++++++
 lib/parse-sku.js |  35 +++++++++++++++++
 lib/pricing.js   |  28 ++++++++++++++
 lib/shopify.js   |  65 ++++++++++++++++++++++++++++++++
 package.json     |  16 ++++++++
 sync.js          | 102 ++++++++++++++++++++++++++++++++++++++++++++++++++
 10 files changed, 587 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..9a5ecaa
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,12 @@
+node_modules/
+.env*
+tmp/
+*.log
+.DS_Store
+dist/
+build/
+.next/
+data/cost-cache.json
+data/dry-run.csv
+data/canary-latest.json
+data/inspect.json
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..f6e46d8
--- /dev/null
+++ b/README.md
@@ -0,0 +1,66 @@
+# fineartamerica-price-sync
+
+Keeps Designer Wallcoverings Shopify prices for **Fine Art America (FAA) fulfilled
+products** (framed prints, posters, canvas, metal — vendor `Steve Abrams Studios`)
+safely above their live FAA fulfillment cost, so an order can never lose money the
+way it did on the Washington Top Hat 20"×24" (sold $110 while costing more to
+fulfill).
+
+## Why this exists
+
+Each Shopify variant SKU **is** a full FAA order string:
+
+```
+artworkid[65269183]-productid[printframed]-imagewidth[20]-imageheight[24]
+-paperid[archivalmattepaper]-frameid[CRQ13]-mat1id[PM918]-mat1width[2]
+-finishid[0.125acrylic]
+```
+
+So cost lookup is deterministic. FAA raises base/material prices over time; static
+Shopify prices silently slide underwater. This syncs them and guards them.
+
+## Pricing rule
+
+`retail = FAA_cost / 0.65 / 0.85` (~**1.81×** cost) — the DW wallcovering formula,
+Steve-selected 2026-07-16.
+
+## Cost source
+
+FAA only prices an exact size/frame/mat/finish combo through its live JS
+configurator, so per-variant cost is fetched via a **logged-in Browserbase
+session** (`bin/faa-fetch.js`) and cached in `data/cost-cache.json` keyed by the
+config string. Plain HTTP only yields the product-type default price.
+
+Credentials (`FAA_EMAIL`, `FAA_PASSWORD`, `SHOPIFY_ADMIN_TOKEN`,
+`BROWSERBASE_*`) live in `~/Projects/secrets-manager/.env` — never in this repo.
+
+## Usage
+
+```bash
+# 1. learn a listing's configurator DOM (once per FAA listing)
+node bin/faa-fetch.js --url "<faa product page>" --inspect
+
+# 2. fetch + cache real FAA costs for a Shopify product's variants
+node bin/faa-fetch.js --url "<faa product page>" --from-shopify 7549840293939
+
+# 3. dry-run the reprice (writes data/dry-run.csv, no live writes)
+node sync.js --product 7549840293939
+
+# 4. GATED live write — only after Steve approves the dry-run
+APPLY_CONFIRM=YES node sync.js --product 7549840293939 --apply
+
+# loss-guard canary (read-only; exits non-zero on any below-cost variant)
+node canary.js --variant 43235375022131
+node canary.js --all
+```
+
+## Hard rules
+
+- `designer-laboratory-sandbox.myshopify.com` is the **LIVE** production store.
+  `--apply` is customer-facing money and requires `APPLY_CONFIRM=YES` + Steve's
+  approval of the dry-run.
+- Never invent a cost. A config with no cached FAA cost is reported `COST-UNKNOWN`
+  and left untouched.
+- Data-integrity watch: a variant SKU's `artworkid` can differ from the current
+  FAA listing id (Washington Top Hat: SKU `65269183` vs live listing `65119339`) —
+  the fetch resolves against the listing you actually fulfill.
diff --git a/bin/faa-fetch.js b/bin/faa-fetch.js
new file mode 100644
index 0000000..5da8c59
--- /dev/null
+++ b/bin/faa-fetch.js
@@ -0,0 +1,112 @@
+#!/usr/bin/env node
+// Populate data/cost-cache.json with real FAA per-config costs by driving the
+// live FAA configurator in a logged-in Browserbase session.
+//
+//   node bin/faa-fetch.js --url "<faa product page>" --sizes 7x8,20x24 --inspect
+//   node bin/faa-fetch.js --url "<faa product page>" --from-shopify 7549840293939
+//
+// --inspect        : dump the configurator's interactive controls + captured
+//                    pricing XHRs (use this first to learn the DOM for a listing).
+// --from-shopify   : read the needed size/frame/mat/finish configs off a Shopify
+//                    product's variant SKUs, fetch each, cache by configKey.
+//
+// METERED: one Browserbase session (~$0.05–0.30). Costs are cached so re-runs
+// only re-fetch configs not already priced.
+
+const fs = require('fs');
+const path = require('path');
+const { newSession, loginFaa, readPrice, loadCache, saveCache } = require('../lib/faa-cost');
+const { parseSku, configKey, configLabel } = require('../lib/parse-sku');
+const { getProduct } = require('../lib/shopify');
+
+const args = process.argv.slice(2);
+const has = f => args.includes(f);
+const val = f => { const i = args.indexOf(f); return i >= 0 ? args[i + 1] : null; };
+const URL0 = val('--url');
+const INSPECT = has('--inspect');
+
+(async () => {
+  if (!URL0) { console.error('need --url <FAA product page>'); process.exit(1); }
+
+  // Determine which configs we need.
+  let configs = [];
+  if (val('--from-shopify')) {
+    const p = await getProduct(val('--from-shopify'));
+    const seen = new Set();
+    for (const v of p.variants) {
+      const cfg = parseSku(v.sku); if (!cfg) continue;
+      const k = configKey(cfg); if (seen.has(k)) continue; seen.add(k);
+      configs.push(cfg);
+    }
+    console.log(`need ${configs.length} distinct FAA configs from Shopify product ${val('--from-shopify')}`);
+  }
+
+  const { session, browser, page } = await newSession();
+  console.log('browserbase session', session.id, '→ https://browserbase.com/sessions/' + session.id);
+  const xhr = [];
+  page.on('response', async r => {
+    try {
+      const u = r.url();
+      if (/configurator|price|orderitem|product/i.test(u)) {
+        const ct = r.headers()['content-type'] || '';
+        if (/json|text|javascript/i.test(ct)) xhr.push({ url: u, status: r.status(), body: (await r.text()).slice(0, 500) });
+      }
+    } catch {}
+  });
+
+  try {
+    const li = await loginFaa(page);
+    console.log('login:', JSON.stringify(li));
+    await page.goto(URL0 + (URL0.includes('?') ? '&' : '?') + 'product=framed-print', { waitUntil: 'domcontentloaded' });
+    await page.waitForTimeout(6000);
+    console.log('landed:', page.url(), '| default price:', await readPrice(page));
+
+    if (INSPECT) {
+      const controls = await page.evaluate(() => {
+        const out = { selects: [], buttons: [], priceEls: [] };
+        document.querySelectorAll('select').forEach(s => out.selects.push({
+          id: s.id, name: s.name, opts: Array.from(s.options).slice(0, 12).map(o => o.value + '=' + o.text.trim()),
+        }));
+        document.querySelectorAll('[onclick*=size],[data-size],[class*=size]').forEach(b =>
+          out.buttons.push({ tag: b.tagName, cls: b.className, txt: (b.textContent || '').trim().slice(0, 40) }));
+        document.querySelectorAll('#productPrice,[class*=productPrice]').forEach(e =>
+          out.priceEls.push({ cls: e.className, txt: e.textContent.trim() }));
+        return out;
+      });
+      fs.writeFileSync(path.join(__dirname, '..', 'data', 'inspect.json'),
+        JSON.stringify({ url: page.url(), controls, xhr }, null, 2));
+      console.log('\n=== CONFIGURATOR CONTROLS ===');
+      console.log(JSON.stringify(controls, null, 2).slice(0, 3000));
+      console.log('\n=== PRICING XHRs ===');
+      console.log(JSON.stringify(xhr.slice(0, 8), null, 2).slice(0, 2000));
+    }
+
+    // Best-effort per-config fetch: FAA exposes size via a #sizeSelect-style
+    // <select>; set it, let the price XHR settle, read #productPrice. Exact
+    // selector is confirmed by --inspect first (data/inspect.json).
+    if (configs.length) {
+      const cache = loadCache();
+      for (const cfg of configs) {
+        try {
+          const sizeVal = `${cfg.imagewidth}x${cfg.imageheight}`;
+          const set = await page.evaluate(({ sizeVal }) => {
+            const sel = document.querySelector('#selectSize, select[name*=size i], select[id*=size i]');
+            if (!sel) return 'no-size-select';
+            const opt = Array.from(sel.options).find(o => o.text.replace(/\s/g, '').includes(sizeVal) || o.value.includes(sizeVal));
+            if (!opt) return 'no-matching-size-option';
+            sel.value = opt.value; sel.dispatchEvent(new Event('change', { bubbles: true }));
+            return 'set';
+          }, { sizeVal });
+          await page.waitForTimeout(3500);
+          const price = await readPrice(page);
+          console.log(`  ${configLabel(cfg)}  → set:${set}  price:${price}`);
+          if (price != null && set === 'set') cache[configKey(cfg)] = price;
+        } catch (e) { console.log('  ! ' + configLabel(cfg) + ' — ' + e.message); }
+      }
+      saveCache(cache);
+      console.log('cost-cache updated:', path.join('data', 'cost-cache.json'));
+    }
+  } finally {
+    await browser.close().catch(() => {});
+  }
+})().catch(e => { console.error(e); process.exit(1); });
diff --git a/canary.js b/canary.js
new file mode 100644
index 0000000..8edf6da
--- /dev/null
+++ b/canary.js
@@ -0,0 +1,66 @@
+#!/usr/bin/env node
+// FAA loss-guard canary (READ-ONLY).
+//
+// The failure this guards against: a Shopify FAA variant price drifting at or
+// below its FAA fulfillment cost — i.e. an order that loses money (exactly what
+// happened on the Washington Top Hat 20"x24"). FAA raises base prices over time
+// and static Shopify prices silently slide underwater; this catches that.
+//
+//   node canary.js --product 7549840293939          # check one product
+//   node canary.js --variant 43235375022131         # check one variant (the canary)
+//   node canary.js --all                             # sweep every FAA product
+//
+// Classifies each variant: LOSS (price <= cost), THIN (margin < floor, default
+// 25%), OK. Emits data/canary-latest.json (heartbeat for the meta-watchdog) and
+// exits non-zero if any LOSS is found. Alerting (CNCP card + George email) is a
+// worsening-only transition — wired by the launchd wrapper, not here — so steady
+// state stays silent.
+
+const fs = require('fs');
+const path = require('path');
+const { parseSku, configKey, configLabel } = require('./lib/parse-sku');
+const { grossMargin } = require('./lib/pricing');
+const { getProduct, listFaaProducts } = require('./lib/shopify');
+const { loadCache } = require('./lib/faa-cost');
+
+const args = process.argv.slice(2);
+const val = f => { const i = args.indexOf(f); return i >= 0 ? args[i + 1] : null; };
+const FLOOR = Number(val('--floor') || 0.25);
+
+(async () => {
+  const costs = loadCache();
+  let products;
+  if (val('--product')) products = [await getProduct(val('--product'))];
+  else if (val('--variant')) {
+    // find the product owning this variant among FAA products
+    const all = await listFaaProducts();
+    products = all.filter(p => p.variants.some(v => String(v.id) === String(val('--variant'))));
+  } else products = await listFaaProducts();
+
+  const findings = [];
+  for (const p of products) {
+    for (const v of p.variants) {
+      if (val('--variant') && String(v.id) !== String(val('--variant'))) continue;
+      const cfg = parseSku(v.sku); if (!cfg) continue;
+      const cost = costs[configKey(cfg)]; const cur = Number(v.price);
+      if (cost == null) { findings.push({ v: v.id, variant: v.title, verdict: 'COST-UNKNOWN', current: cur }); continue; }
+      const margin = grossMargin(cost, cur);
+      const verdict = cur <= cost ? 'LOSS' : margin < FLOOR ? 'THIN' : 'OK';
+      findings.push({ v: v.id, product: p.title, variant: v.title, config: configLabel(cfg),
+        faaCost: cost, current: cur, margin: +(margin).toFixed(3), verdict });
+    }
+  }
+
+  const loss = findings.filter(f => f.verdict === 'LOSS');
+  const thin = findings.filter(f => f.verdict === 'THIN');
+  const summary = { checked: findings.length, loss: loss.length, thin: thin.length,
+    floor: FLOOR, worst: loss[0] || thin[0] || null };
+  const latest = { ts: new Date().toISOString(), summary, findings };
+  fs.writeFileSync(path.join(__dirname, 'data', 'canary-latest.json'), JSON.stringify(latest, null, 2));
+
+  console.log(`FAA loss-guard: checked ${findings.length}  LOSS ${loss.length}  THIN ${thin.length}  (margin floor ${FLOOR*100}%)`);
+  for (const f of [...loss, ...thin]) {
+    console.log(`  ${f.verdict.padEnd(4)} ${f.variant.padEnd(30)} cost $${f.faaCost}  price $${f.current}  margin ${(f.margin*100).toFixed(0)}%`);
+  }
+  process.exit(loss.length ? 1 : 0);
+})().catch(e => { console.error(e); process.exit(3); });
diff --git a/lib/faa-cost.js b/lib/faa-cost.js
new file mode 100644
index 0000000..6aa9147
--- /dev/null
+++ b/lib/faa-cost.js
@@ -0,0 +1,85 @@
+// Fine Art America cost resolver.
+//
+// TWO TIERS (see recon 2026-07-16):
+//   (1) HTTP fast path — FAA renders `#productPrice` server-side from the
+//       ?product=<type> URL param. Free, no login, but only the DEFAULT config
+//       for that product type (e.g. framed default = $74 at 7"x8").
+//   (2) Browserbase configurator — FAA only prices the exact size/frame/mat/
+//       finish combo through its live JS configurator, so per-variant cost needs
+//       a real browser session (logged in as the seller so pricing + availability
+//       reflect Steve's account). This is the metered path (~$0.10–0.30/session).
+//
+// Every fetched cost is cached to data/cost-cache.json keyed by the config string
+// so re-runs only re-fetch changed configs (FAA raises base prices over time —
+// that drift is exactly what the recurring sync + loss-guard canary catch).
+
+const fs = require('fs');
+const path = require('path');
+const { configKey } = require('./parse-sku');
+
+const BB = '/Users/macstudio3/.claude/skills/browserbase';
+const SECRETS = '/Users/macstudio3/Projects/secrets-manager/.env';
+const CACHE = path.join(__dirname, '..', 'data', 'cost-cache.json');
+
+function secret(k) {
+  const m = fs.readFileSync(SECRETS, 'utf8').match(new RegExp('^' + k + '=(.*)$', 'm'));
+  return m ? m[1].trim().replace(/^["']|["']$/g, '') : null;
+}
+function loadCache() { try { return JSON.parse(fs.readFileSync(CACHE, 'utf8')); } catch { return {}; } }
+function saveCache(c) { fs.writeFileSync(CACHE, JSON.stringify(c, null, 2)); }
+
+// ---- Tier 1: HTTP product-type default price -------------------------------
+async function httpTypeDefaultPrice(pageUrl, productType) {
+  const u = new URL(pageUrl);
+  u.searchParams.set('product', productType);
+  const html = await (await fetch(u.toString(), {
+    headers: { 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)' },
+  })).text();
+  const m = html.match(/productPrice'>([0-9]+\.[0-9]{2})/);
+  return m ? parseFloat(m[1]) : null;
+}
+
+// ---- Tier 2: Browserbase configurator --------------------------------------
+async function newSession() {
+  const { chromium } = require(BB + '/node_modules/playwright-core');
+  const Browserbase = require(BB + '/node_modules/@browserbasehq/sdk').default;
+  const bb = new Browserbase({ apiKey: secret('BROWSERBASE_API_KEY') });
+  const session = await bb.sessions.create({
+    projectId: secret('BROWSERBASE_PROJECT_ID'),
+    browserSettings: { solveCaptchas: true },
+  });
+  const browser = await chromium.connectOverCDP(session.connectUrl);
+  const ctx = browser.contexts()[0];
+  const page = ctx.pages()[0] || await ctx.newPage();
+  page.setDefaultTimeout(45000);
+  return { bb, session, browser, ctx, page };
+}
+
+async function loginFaa(page) {
+  const email = secret('FAA_EMAIL'), pass = secret('FAA_PASSWORD');
+  await page.goto('https://fineartamerica.com/login.html', { waitUntil: 'domcontentloaded' });
+  await page.waitForTimeout(2500);
+  const emailSel = await page.$('input[name=email], #email, input[type=email]');
+  if (!emailSel) return { ok: !!(await page.$('a[href*=logout]')), note: 'no login form (maybe already in)' };
+  await page.fill('input[name=email], #email, input[type=email]', email);
+  await page.fill('input[name=password], #password, input[type=password]', pass);
+  await page.waitForTimeout(400);
+  await page.keyboard.press('Enter');
+  await page.waitForTimeout(4000);
+  return { ok: !/login\.html/.test(page.url()), url: page.url() };
+}
+
+// Read the currently-rendered configurator price.
+async function readPrice(page) {
+  return page.evaluate(() => {
+    const el = document.querySelector('#productPrice, .productPrice, [class*=productPrice]');
+    if (!el) return null;
+    const n = parseFloat((el.textContent || '').replace(/[^0-9.]/g, ''));
+    return isNaN(n) ? null : n;
+  });
+}
+
+module.exports = {
+  httpTypeDefaultPrice, newSession, loginFaa, readPrice,
+  loadCache, saveCache, configKey, secret,
+};
diff --git a/lib/parse-sku.js b/lib/parse-sku.js
new file mode 100644
index 0000000..dc5a04d
--- /dev/null
+++ b/lib/parse-sku.js
@@ -0,0 +1,35 @@
+// Parse a Designer-Wallcoverings Shopify variant SKU that encodes a Fine Art
+// America order configuration, e.g.:
+//   artworkid[65269183]-productid[printframed]-imagewidth[20]-imageheight[24]
+//   -paperid[archivalmattepaper]-frameid[CRQ13]-mat1id[PM918]-mat1width[2]
+//   -finishid[0.125acrylic]
+//
+// The SKU IS the contract between Shopify and FAA — every field needed to price
+// the item on FAA is embedded here, so cost lookup is deterministic (no fuzzy
+// title matching). Returns null for SKUs that carry no FAA signature.
+
+function parseSku(sku) {
+  if (!sku || !/artworkid\[/i.test(sku) || !/productid\[/i.test(sku)) return null;
+  const cfg = {};
+  const re = /([a-z0-9]+)\[([^\]]*)\]/gi;
+  let m;
+  while ((m = re.exec(sku))) cfg[m[1].toLowerCase()] = m[2];
+  cfg._raw = sku;
+  return cfg;
+}
+
+// A stable cache key for one FAA production configuration (artwork-independent
+// fields collapse re-fetches across identical frame/size/mat combos).
+function configKey(cfg) {
+  return [
+    cfg.artworkid, cfg.productid, cfg.imagewidth, cfg.imageheight,
+    cfg.paperid, cfg.frameid, cfg.mat1id, cfg.mat1width, cfg.finishid,
+  ].join('|');
+}
+
+// Human label for logs / dry-run tables.
+function configLabel(cfg) {
+  return `${cfg.productid} ${cfg.imagewidth}x${cfg.imageheight} frame:${cfg.frameid || '-'} mat:${cfg.mat1id || '-'} finish:${cfg.finishid || '-'}`;
+}
+
+module.exports = { parseSku, configKey, configLabel };
diff --git a/lib/pricing.js b/lib/pricing.js
new file mode 100644
index 0000000..cbb19c9
--- /dev/null
+++ b/lib/pricing.js
@@ -0,0 +1,28 @@
+// Retail pricing rule for Fine Art America fulfilled products on the DW Shopify
+// store. Steve-selected 2026-07-16 (AskUserQuestion): the DW wallcovering
+// formula — retail = cost / 0.65 / 0.85 (~1.81x the FAA fulfillment cost).
+//
+// "cost" here = the FAA price for the exact configuration encoded in the variant
+// SKU (see lib/faa-cost.js). This guarantees Shopify retail sits safely above
+// fulfillment cost so an order can never lose money the way the pre-fix prices
+// did (e.g. 7"x8" framed selling at $42.22 against a ~$74 FAA cost).
+
+const DISCOUNT_A = 0.65;
+const DISCOUNT_B = 0.85;
+
+function retailFromCost(cost, { round = 'cent' } = {}) {
+  if (cost == null || isNaN(cost) || cost <= 0) return null;
+  let r = cost / DISCOUNT_A / DISCOUNT_B;
+  if (round === 'dollar') r = Math.ceil(r);            // round UP to whole dollar
+  else if (round === '.99') r = Math.ceil(r) - 0.01;   // charm price
+  else r = Math.round(r * 100) / 100;                  // 2-decimal
+  return r;
+}
+
+// Margin sanity: fraction of retail that is gross profit after FAA cost.
+function grossMargin(cost, retail) {
+  if (!retail) return null;
+  return (retail - cost) / retail;
+}
+
+module.exports = { retailFromCost, grossMargin, DISCOUNT_A, DISCOUNT_B };
diff --git a/lib/shopify.js b/lib/shopify.js
new file mode 100644
index 0000000..9711c91
--- /dev/null
+++ b/lib/shopify.js
@@ -0,0 +1,65 @@
+// Thin Shopify Admin REST client for the LIVE Designer Wallcoverings store.
+// NOTE: designer-laboratory-sandbox.myshopify.com is the real, customer-facing
+// production store despite the "sandbox" handle. Every variant price write here
+// is customer-facing money — callers must gate writes (see sync.js --apply).
+
+const fs = require('fs');
+
+const SECRETS = '/Users/macstudio3/Projects/secrets-manager/.env';
+const STORE = 'designer-laboratory-sandbox.myshopify.com';
+const API = '2024-10';
+const VENDOR = 'Steve Abrams Studios';
+
+function env(key) {
+  const m = fs.readFileSync(SECRETS, 'utf8').match(new RegExp('^' + key + '=(.*)$', 'm'));
+  return m ? m[1].trim().replace(/^["']|["']$/g, '') : null;
+}
+const TOKEN = env('SHOPIFY_ADMIN_TOKEN');
+
+async function shopify(path, opts = {}) {
+  const res = await fetch(`https://${STORE}/admin/api/${API}/${path}`, {
+    ...opts,
+    headers: {
+      'X-Shopify-Access-Token': TOKEN,
+      'Content-Type': 'application/json',
+      ...(opts.headers || {}),
+    },
+  });
+  const text = await res.text();
+  if (!res.ok) throw new Error(`Shopify ${res.status} ${path}: ${text.slice(0, 300)}`);
+  return { json: text ? JSON.parse(text) : null, headers: res.headers };
+}
+
+async function getProduct(id) {
+  const { json } = await shopify(`products/${id}.json`);
+  return json.product;
+}
+
+// All FAA-fulfilled products = vendor "Steve Abrams Studios" whose variants
+// carry the FAA SKU signature. Paginates the vendor set.
+async function listFaaProducts() {
+  const out = [];
+  let url = `products.json?vendor=${encodeURIComponent(VENDOR)}&limit=250`;
+  while (url) {
+    const { json, headers } = await shopify(url);
+    for (const p of json.products) {
+      const isFaa = (p.variants || []).some(v => v.sku && /artworkid\[/.test(v.sku) && /productid\[/.test(v.sku));
+      if (isFaa) out.push(p);
+    }
+    const link = headers.get('link') || '';
+    const next = link.match(/<[^>]*[?&]([^>]*page_info=[^>&]+)[^>]*>;\s*rel="next"/);
+    url = next ? `products.json?limit=250&${next[1]}` : null;
+  }
+  return out;
+}
+
+// Gated write. Returns the updated variant.
+async function updateVariantPrice(variantId, price) {
+  const { json } = await shopify(`variants/${variantId}.json`, {
+    method: 'PUT',
+    body: JSON.stringify({ variant: { id: variantId, price: String(price) } }),
+  });
+  return json.variant;
+}
+
+module.exports = { shopify, getProduct, listFaaProducts, updateVariantPrice, STORE, VENDOR };
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..3132d62
--- /dev/null
+++ b/package.json
@@ -0,0 +1,16 @@
+{
+  "name": "fineartamerica-price-sync",
+  "version": "0.1.0",
+  "private": true,
+  "description": "Sync Designer Wallcoverings Shopify prices for Fine Art America fulfilled products (framed prints, posters, canvas) to cost/0.65/0.85 of the live FAA cost, so orders never sell below fulfillment cost. Includes a loss-guard canary.",
+  "bin": {
+    "faa-sync": "sync.js",
+    "faa-canary": "canary.js",
+    "faa-fetch": "bin/faa-fetch.js"
+  },
+  "scripts": {
+    "dry-run": "node sync.js --product 7549840293939",
+    "canary": "node canary.js --all"
+  },
+  "author": "steve@designerwallcoverings.com"
+}
diff --git a/sync.js b/sync.js
new file mode 100644
index 0000000..bca2acb
--- /dev/null
+++ b/sync.js
@@ -0,0 +1,102 @@
+#!/usr/bin/env node
+// FAA → Shopify price sync.
+//
+//   node sync.js --product 7549840293939        # dry-run ONE product (pilot)
+//   node sync.js --all                          # dry-run every FAA product
+//   node sync.js --product <id> --apply         # WRITE live prices (GATED)
+//
+// Cost source = data/cost-cache.json (config-string -> FAA cost), populated by
+// bin/faa-fetch.js. A variant whose config has no cached cost is reported as
+// COST-UNKNOWN and never repriced (we never invent a cost).
+//
+// --apply is customer-facing money on the LIVE store, so it refuses to run
+// unless APPLY_CONFIRM=YES is also set — the two-key rule. Even then the
+// intended flow is: dry-run -> draft to pending-approval -> Steve approves ->
+// APPLY_CONFIRM=YES apply.
+
+const fs = require('fs');
+const path = require('path');
+const { parseSku, configKey, configLabel } = require('./lib/parse-sku');
+const { retailFromCost, grossMargin } = require('./lib/pricing');
+const { getProduct, listFaaProducts, updateVariantPrice } = require('./lib/shopify');
+const { loadCache } = require('./lib/faa-cost');
+
+const args = process.argv.slice(2);
+const has = f => args.includes(f);
+const val = f => { const i = args.indexOf(f); return i >= 0 ? args[i + 1] : null; };
+const APPLY = has('--apply');
+const ROUND = val('--round') || 'cent';
+
+(async () => {
+  const costs = loadCache();
+  const products = val('--product')
+    ? [await getProduct(val('--product'))]
+    : has('--all') ? await listFaaProducts()
+    : (console.error('specify --product <id> or --all'), process.exit(1));
+
+  const rows = [];
+  for (const p of products) {
+    for (const v of p.variants) {
+      const cfg = parseSku(v.sku);
+      if (!cfg) continue;
+      const key = configKey(cfg);
+      const cost = costs[key] != null ? Number(costs[key]) : null;
+      const cur = Number(v.price);
+      const target = cost != null ? retailFromCost(cost, { round: ROUND }) : null;
+      rows.push({
+        product: p.title, productId: p.id, variantId: v.id,
+        variant: v.title, config: configLabel(cfg),
+        faaCost: cost, current: cur, target,
+        margin: cost != null && target ? grossMargin(cost, target) : null,
+        losing: cost != null ? cur < cost : null,
+        status: cost == null ? 'COST-UNKNOWN'
+              : target == null ? 'NO-TARGET'
+              : Math.abs(target - cur) < 0.005 ? 'OK'
+              : 'REPRICE',
+      });
+    }
+  }
+
+  // ---- report ----
+  const money = n => n == null ? '   —   ' : ('$' + n.toFixed(2)).padStart(8);
+  const pct = n => n == null ? '  — ' : (n * 100).toFixed(0).padStart(3) + '%';
+  console.log(`\nFAA price sync — ${products.length} product(s), ${rows.length} FAA variants`);
+  console.log('cost source: data/cost-cache.json   rule: cost/0.65/0.85 (~1.81x)   round:', ROUND);
+  console.log('─'.repeat(112));
+  console.log('  '+'variant'.padEnd(30)+'  '+'FAA cost'.padStart(8)+'  '+'current'.padStart(8)+'  '+'→ target'.padStart(8)+'  margin  status');
+  console.log('─'.repeat(112));
+  for (const r of rows) {
+    const flag = r.losing ? ' ⚠LOSS' : '';
+    console.log('  '+r.variant.slice(0,30).padEnd(30)+'  '+money(r.faaCost)+'  '+money(r.current)+'  '+money(r.target)+'  '+pct(r.margin)+'  '+r.status+flag);
+  }
+  const known = rows.filter(r => r.faaCost != null);
+  const losing = rows.filter(r => r.losing);
+  console.log('─'.repeat(112));
+  console.log(`costed: ${known.length}/${rows.length}   currently-below-cost: ${losing.length}   to-reprice: ${rows.filter(r=>r.status==='REPRICE').length}`);
+
+  // ---- CSV artifact ----
+  const csv = ['product,variant_id,variant,config,faa_cost,current,target,margin,status,losing']
+    .concat(rows.map(r => [JSON.stringify(r.product), r.variantId, JSON.stringify(r.variant), JSON.stringify(r.config),
+      r.faaCost ?? '', r.current, r.target ?? '', r.margin != null ? r.margin.toFixed(4) : '', r.status, r.losing ?? ''].join(',')))
+    .join('\n');
+  const out = path.join(__dirname, 'data', 'dry-run.csv');
+  fs.writeFileSync(out, csv);
+  console.log('wrote', out);
+
+  // ---- gated apply ----
+  if (!APPLY) { console.log('\n(dry-run — no live writes. Re-run with --apply APPLY_CONFIRM=YES to write.)'); return; }
+  if (process.env.APPLY_CONFIRM !== 'YES') {
+    console.error('\nREFUSED: --apply requires APPLY_CONFIRM=YES (customer-facing live write). Aborting.');
+    process.exit(2);
+  }
+  const toWrite = rows.filter(r => r.status === 'REPRICE');
+  console.log(`\nAPPLYING ${toWrite.length} live price writes to the production store…`);
+  let ok = 0;
+  for (const r of toWrite) {
+    await updateVariantPrice(r.variantId, r.target.toFixed(2));
+    ok++;
+    console.log(`  ✓ ${r.variant.padEnd(30)} $${r.current.toFixed(2)} → $${r.target.toFixed(2)}`);
+    await new Promise(s => setTimeout(s, 550)); // Shopify 2 req/s courtesy
+  }
+  console.log(`\nDONE: ${ok}/${toWrite.length} variants repriced.`);
+})().catch(e => { console.error(e); process.exit(1); });

(oldest)  ·  back to Fineartamerica Price Sync  ·  Free HTTP FAA cost fetcher (stateful configurator) — pilot: 54dfec4 →