diff --git a/app/settings_routes.py b/app/settings_routes.py index 97ac28c..fa542a8 100644 --- a/app/settings_routes.py +++ b/app/settings_routes.py @@ -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", } diff --git a/app/shop_routes.py b/app/shop_routes.py index e355997..dae76c0 100644 --- a/app/shop_routes.py +++ b/app/shop_routes.py @@ -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}