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:
parent
bfe6a08dfb
commit
8a3d833986
@ -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",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user