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