A South African men's apparel store on Spree 5.6 + Next.js 15. 160 products
live, three markets, live Malaysian courier rates, customer-owned carts.
What is here:
storefront/ the Next.js app
backend/ ONLY the custom Spree code. Spree itself is a prebuilt image
that mounts no app code, so every file here needs an explicit
volume entry in docker-compose.yml or it is silently ignored.
catalogue/ the scripts that loaded and maintain the products
docs/ manifest, mock registry, napkins
Real .env files are committed deliberately. This lives on the house's private
Gitea, and a clone that runs beats a clone that needs a secrets handover.
LANDMINE.md maps every key, what it opens, and what must be rotated before this
repo goes anywhere less private.
The admin API key used to be a literal in six catalogue scripts. They now read
SPREE_ADMIN_KEY from the environment and refuse to run without it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
186 lines
6.2 KiB
Python
186 lines
6.2 KiB
Python
#!/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=<the admin API 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()
|