feat(shop): WP/Woo bridge endpoints — /shop/item, /shop/shipping/quote (public), /shop/woo-order (key-gated)

The thin WP plugin renders from /shop/*, mints Woo products on the fly via /shop/item, quotes
postage publicly, and posts completed orders back to /shop/woo-order (X-Bridge-Key vs vault
bridge_key) — marks SKUs sold + logs the online sale, idempotent on WC-<order#>.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-06-24 01:28:34 +10:00
parent bfe6a08dfb
commit 8a3d833986
2 changed files with 79 additions and 1 deletions

View File

@ -31,6 +31,7 @@ KNOWN = {
"square_access_token": "Square access token (EAAA…) — drives the payment terminal",
"square_location_id": "Square location ID (e.g. S5D3EN3YM2AN8)",
"square_environment": "Square environment — production or sandbox",
"bridge_key": "WordPress bridge shared secret — the WP plugin sends this to post back orders",
}

View File

@ -3,10 +3,12 @@ import json
import re
import httpx
from fastapi import APIRouter, Depends, Query, HTTPException
from fastapi import APIRouter, Depends, Query, HTTPException, Header
from pydantic import BaseModel
from sqlalchemy import text
from . import vault
_YT = re.compile(r"(?:v=|youtu\.be/|embed/|/v/)([\w-]{11})")
@ -301,3 +303,78 @@ async def shop_wantlist(body: WantIn, db=Depends(get_db)):
RETURNING id"""), {**body.model_dump(), "pref": pref})).scalar()
await db.commit()
return {"ok": True, "id": rid}
# ─── WordPress / Woo bridge ──────────────────────────────────────────────────────────────────
# The thin RecordGod WP plugin renders the storefront from /shop/*, creates Woo products on the fly
# at add-to-cart (via /shop/item), quotes postage (/shop/shipping/quote), and posts back completed
# orders (/shop/woo-order, bridge-key gated) so RecordGod stays the single source of truth.
@router.get("/item/{sku}")
async def shop_item(sku: str, db=Depends(get_db)):
"""One inventory item by SKU — what the bridge needs to mint a Woo product on the fly."""
r = (await db.execute(text("""
SELECT i.sku, coalesce(i.title, dc.title) AS title, dc.artist, dc.thumb,
i.price::float AS price, i.in_stock, i.condition, i.release_id
FROM inventory i LEFT JOIN disc_cache dc ON dc.release_id = i.release_id
WHERE i.sku = :s AND i.store_id = 1"""), {"s": sku})).mappings().first()
if not r:
raise HTTPException(404, "item not found")
return dict(r)
@router.get("/shipping/quote")
async def shop_shipping_quote(units: int | None = None, weight_g: int | None = None, db=Depends(get_db)):
"""Public AusPost quote for the WC shipping method (rates aren't secret)."""
if weight_g is None:
weight_g = max(1, units or 1) * 280
parcels = max(1, -(-weight_g // 5000))
per = -(-weight_g // parcels)
rows = await db.execute(text(
"SELECT service_key, price::float AS price FROM post_flat_rate "
"WHERE :w BETWEEN weight_min_g AND weight_max_g"), {"w": per})
seen = {r["service_key"]: r["price"] for r in rows.mappings()}
opts = [{"service": k, "label": k.replace("_", " ").title(), "price": round(v * parcels, 2)}
for k, v in sorted(seen.items(), key=lambda x: x[1])]
return {"weight_g": weight_g, "parcels": parcels, "options": opts}
class WooOrderIn(BaseModel):
order_number: str
email: str | None = None
name: str | None = None
total: float = 0
items: list[dict] = [] # [{sku, name, qty, price}]
@router.post("/woo-order")
async def woo_order(body: WooOrderIn, x_bridge_key: str = Header(None), db=Depends(get_db)):
"""Record a completed Woo order back into RecordGod (mark sold + log the online sale). Bridge-key gated."""
key = await vault.get_secret(db, "bridge_key")
if not key or x_bridge_key != key:
raise HTTPException(401, "bad or missing bridge key")
sn = "WC-" + str(body.order_number)
if (await db.execute(text("SELECT 1 FROM sales WHERE sale_number = :n"), {"n": sn})).first():
return {"ok": True, "duplicate": True}
cust = None
if body.email:
c = (await db.execute(text("SELECT id FROM customer WHERE lower(email)=lower(:e) ORDER BY id LIMIT 1"),
{"e": body.email})).first()
cust = c[0] if c else None
sale_id = (await db.execute(text("""
INSERT INTO sales (sale_number, customer_id, subtotal, total, status, payment_method, payment_status,
amount_paid, sale_date, created_at)
VALUES (:sn, :cust, :tot, :tot, 'completed', 'woo', 'paid', :tot, now(), now()) RETURNING id"""),
{"sn": sn, "cust": cust, "tot": body.total})).scalar()
for it in body.items:
await db.execute(text("""
INSERT INTO sale_items (sale_id, sku, item_name, qty, unit_price, line_total)
VALUES (:s, :sku, :n, :q, :p, :lt)"""),
{"s": sale_id, "sku": it.get("sku"), "n": it.get("name") or it.get("sku"),
"q": it.get("qty", 1), "p": it.get("price", 0),
"lt": float(it.get("price", 0)) * int(it.get("qty", 1))})
skus = [it["sku"] for it in body.items if it.get("sku")]
if skus:
await db.execute(text("UPDATE inventory SET in_stock=false, status='sold', sold_date=now() "
"WHERE sku = ANY(:s) AND store_id = 1"), {"s": skus})
await db.commit()
return {"ok": True, "sale_id": sale_id, "sale_number": sn}