← back to Fineartamerica Price Sync

sync.js

114 lines

#!/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, err = 0;
  for (const r of toWrite) {
    // resilient write: retry on 429/5xx with backoff; never let one bad call
    // abort the whole batch (a single rate-limit used to leave it partial).
    let done = false;
    for (let attempt = 1; attempt <= 5 && !done; attempt++) {
      try {
        await updateVariantPrice(r.variantId, r.target.toFixed(2));
        ok++; done = true;
        console.log(`  ✓ ${r.variant.padEnd(30)} $${r.current.toFixed(2)} → $${r.target.toFixed(2)}`);
      } catch (e) {
        const transient = /\b(429|5\d\d)\b/.test(e.message);
        if (transient && attempt < 5) { await new Promise(s => setTimeout(s, 1000 * attempt)); }
        else { err++; done = true; console.log(`  ✗ ${r.variant.padEnd(30)} — ${e.message.slice(0, 80)}`); }
      }
    }
    await new Promise(s => setTimeout(s, 600)); // Shopify ~2 req/s courtesy
  }
  console.log(`\nDONE: ${ok}/${toWrite.length} repriced${err ? `, ${err} errored` : ''}.`);
})().catch(e => { console.error(e); process.exit(1); });