#!/usr/bin/env python3 """ Reprice all products from the source MYR catalogue. Replaces the prices written by fix_prices.py, which multiplied by 4.8 instead of dividing — putting every product on the store at ~23x its real price. Writes BOTH currency rows the store supports: ZAR (store default) = price_myr * MYR_TO_ZAR USD (still in supported_currencies) = price_myr * MYR_TO_USD The USD row is corrected in the same pass on purpose: leaving the old 23x rows behind means anyone switching the store currency to USD sees the broken prices again, with nothing to warn them. Rates are fetched live and printed. No hardcoded rate, no fallback rate — if the fetch fails this exits rather than pricing a live store off a guess. Usage: reprice_zar.py --dry-run # show the changes, write nothing (do this first) reprice_zar.py # apply, then read back and check every row """ import json import sys import time from pathlib import Path import requests import os as _os def _spree_admin_key() -> str: """The Spree admin API key. Required — never defaulted.""" key = _os.environ.get("SPREE_ADMIN_KEY", "").strip() if not key: raise SystemExit( "SPREE_ADMIN_KEY is not set.\n" " export SPREE_ADMIN_KEY=\n" "It is deliberately not stored in this file — see README.md." ) return key API = "https://api.jux-lab.com/api/v3/admin" H = {"X-Spree-Api-Key": _spree_admin_key(), "Content-Type": "application/json"} PRODUCTS = Path("/root/Opus/Projects/PeakyBuildr/scraped-catalogue/products") DRY = "--dry-run" in sys.argv def fatal(msg): print(f"\nFATAL: {msg}", file=sys.stderr) sys.exit(1) # --- rates ------------------------------------------------------------------- def get_rates(): """Live MYR base rates. Loud failure beats a stale constant — see fix_prices.py.""" try: d = requests.get("https://open.er-api.com/v6/latest/MYR", timeout=20).json() except Exception as e: fatal(f"rate fetch failed: {e}") r = d.get("rates") or {} zar, usd = r.get("ZAR"), r.get("USD") if not zar or not usd: fatal(f"rate response missing ZAR/USD: {d}") print(f"rates 1 MYR = {zar} ZAR = {usd} USD ({d.get('time_last_update_utc')})") return float(zar), float(usd) # --- source catalogue -------------------------------------------------------- def load_source(): prices = {} for folder in sorted(PRODUCTS.iterdir()): info = folder / "info.json" if not info.exists(): continue myr = json.loads(info.read_text()).get("price_myr") if myr is None: fatal(f"{folder.name}/info.json has no price_myr") prices[folder.name] = float(myr) if not prices: fatal(f"no source prices found under {PRODUCTS}") print(f"source {len(prices)} products, MYR {min(prices.values()):.2f}-{max(prices.values()):.2f}") return prices def fetch_products(): items, page = [], 1 while True: d = requests.get(f"{API}/products?per_page=100&page={page}", headers=H, timeout=60).json() items += d.get("data", []) if page >= d.get("meta", {}).get("pages", 1): break page += 1 return items def main(): zar_rate, usd_rate = get_rates() src = load_source() items = fetch_products() print(f"store {len(items)} products") planned, missing = {}, [] for p in items: slug = p["slug"] myr = src.get(slug) if myr is None: missing.append(slug) continue planned[p["id"]] = { "slug": slug, "myr": myr, "ZAR": round(myr * zar_rate, 2), "USD": round(myr * usd_rate, 2), } if missing: # Not fatal: these keep their current prices. But they must be named, not # silently skipped — a product left at 23x is the whole bug again. print(f"\n{len(missing)} products have NO source price and will be LEFT UNCHANGED:") for s in missing: print(f" ! {s}") sample = list(planned.values())[:5] print(f"\n{len(planned)} to reprice. Sample:") for s in sample: print(f" MYR {s['myr']:>7.2f} -> ZAR {s['ZAR']:>8.2f} / USD {s['USD']:>7.2f} {s['slug'][:44]}") zars = [v["ZAR"] for v in planned.values()] print(f" new ZAR range: {min(zars):.2f} - {max(zars):.2f}") if DRY: print("\n--dry-run: nothing written.") return # --- apply --------------------------------------------------------------- print() ok = fail = 0 for n, (pid, s) in enumerate(planned.items(), 1): body = {"prices": [ {"amount": s["ZAR"], "currency": "ZAR"}, {"amount": s["USD"], "currency": "USD"}, ]} try: r = requests.patch(f"{API}/products/{pid}", headers=H, json=body, timeout=60).json() except Exception as e: fail += 1 print(f" ERROR {s['slug'][:40]}: {e}") continue if "error" in r: fail += 1 print(f" FAIL {s['slug'][:40]}: {r['error'].get('message')}") else: ok += 1 if n % 40 == 0: print(f" {n}/{len(planned)}") time.sleep(0.12) print(f"patched ok={ok} fail={fail}") # --- read back ----------------------------------------------------------- # The store returns 200 for a price write that never created a row in the # store currency. Trusting that 200 is what hid 130 products for an hour. print("\nreading back...") time.sleep(2) bad = [] for p in fetch_products(): want = planned.get(p["id"]) if not want: continue a = p.get("attributes", p) got = a.get("price") cur = a.get("currency") if got is None or abs(float(got) - want["ZAR"]) > 0.01 or cur != "ZAR": bad.append((want["slug"], want["ZAR"], got, cur)) if bad: print(f"\n{len(bad)} products did NOT take the ZAR price:") for slug, want, got, cur in bad[:15]: print(f" ! {slug[:40]} want ZAR {want} got {got} {cur}") sys.exit(1) print(f"check ok: all {len(planned)} products report the expected ZAR price.") if __name__ == "__main__": main()