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_access_token": "Square access token (EAAA…) — drives the payment terminal",
"square_location_id": "Square location ID (e.g. S5D3EN3YM2AN8)", "square_location_id": "Square location ID (e.g. S5D3EN3YM2AN8)",
"square_environment": "Square environment — production or sandbox", "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 re
import httpx import httpx
from fastapi import APIRouter, Depends, Query, HTTPException from fastapi import APIRouter, Depends, Query, HTTPException, Header
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import text from sqlalchemy import text
from . import vault
_YT = re.compile(r"(?:v=|youtu\.be/|embed/|/v/)([\w-]{11})") _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() RETURNING id"""), {**body.model_dump(), "pref": pref})).scalar()
await db.commit() await db.commit()
return {"ok": True, "id": rid} 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}