Batch-committing the in-flight app work (all modules compile clean): - app/payments.py, app/packs.py, app/discogs_mp.py, app/dealgod.py (new) - admin_routes, main, navigator/sales/shop routes, site/search.html - customer_woo_sync + disc_sync tweaks; test_dealgod_supply, test_startup_ddl Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
515 lines
27 KiB
Python
515 lines
27 KiB
Python
import asyncio
|
|
import html
|
|
import json
|
|
import re
|
|
import secrets
|
|
from datetime import datetime
|
|
|
|
import httpx
|
|
from fastapi import APIRouter, Depends, Query, HTTPException, Header
|
|
from fastapi.responses import HTMLResponse
|
|
from pydantic import BaseModel
|
|
from sqlalchemy import text
|
|
|
|
from . import vault
|
|
|
|
_YT = re.compile(r"(?:v=|youtu\.be/|embed/|/v/)([\w-]{11})")
|
|
|
|
|
|
def _ytid(uri):
|
|
if not uri:
|
|
return None
|
|
m = _YT.search(uri)
|
|
if m:
|
|
return m.group(1)
|
|
return uri if re.fullmatch(r"[\w-]{11}", uri) else None
|
|
|
|
from .db import get_db
|
|
from .layout_routes import DEFAULTS
|
|
|
|
# PUBLIC storefront API — the customer-facing shop reads its theme/layout + catalog here.
|
|
# No auth (theme + in-stock catalog aren't secret); driven by what the builder saves.
|
|
router = APIRouter(prefix="/shop", tags=["shop"])
|
|
|
|
|
|
async def _tracking_head(db) -> str:
|
|
"""Analytics/marketing snippets for the storefront <head> — Meta Pixel, Umami, GA4 (all opt-in via settings)."""
|
|
rows = await db.execute(text(
|
|
"SELECT setting_key, setting_value FROM sales_setting "
|
|
"WHERE setting_key IN ('meta_pixel_id','umami_website_id','umami_src','ga4_id') AND is_active"))
|
|
s = {r["setting_key"]: (r["setting_value"] or "").strip() for r in rows.mappings()}
|
|
out = []
|
|
if s.get("meta_pixel_id"):
|
|
pid = s["meta_pixel_id"]
|
|
out.append("<script>!function(f,b,e,v,n,t,s){if(f.fbq)return;n=f.fbq=function(){n.callMethod?"
|
|
"n.callMethod.apply(n,arguments):n.queue.push(arguments)};if(!f._fbq)f._fbq=n;n.push=n;"
|
|
"n.loaded=!0;n.version='2.0';n.queue=[];t=b.createElement(e);t.async=!0;t.src=v;"
|
|
"s=b.getElementsByTagName(e)[0];s.parentNode.insertBefore(t,s)}(window,document,'script',"
|
|
f"'https://connect.facebook.net/en_US/fbevents.js');fbq('init','{pid}');fbq('track','PageView');</script>"
|
|
f'<noscript><img height="1" width="1" style="display:none" '
|
|
f'src="https://www.facebook.com/tr?id={pid}&ev=PageView&noscript=1"/></noscript>')
|
|
if s.get("umami_website_id"):
|
|
src = s.get("umami_src") or "https://cloud.umami.is/script.js"
|
|
out.append(f'<script defer src="{src}" data-website-id="{s["umami_website_id"]}"></script>')
|
|
if s.get("ga4_id"):
|
|
g = s["ga4_id"]
|
|
out.append(f'<script async src="https://www.googletagmanager.com/gtag/js?id={g}"></script>'
|
|
"<script>window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments);}"
|
|
f"gtag('js',new Date());gtag('config','{g}');</script>")
|
|
return "".join(out)
|
|
|
|
|
|
@router.get("/page/{slug}", response_class=HTMLResponse)
|
|
async def shop_page(slug: str, db=Depends(get_db)):
|
|
"""Server-rendered content page (About/Shipping/Returns/…) — SEO-friendly HTML, the WP Pages public view."""
|
|
p = (await db.execute(text(
|
|
"SELECT title, body, seo_title, seo_description FROM page WHERE slug = :s AND published"),
|
|
{"s": slug})).mappings().first()
|
|
if not p:
|
|
raise HTTPException(404, "page not found")
|
|
name = (await db.execute(text("SELECT setting_value FROM sales_setting WHERE setting_key='store_name'"))).scalar() or "Store"
|
|
e = html.escape
|
|
title = p["seo_title"] or f'{p["title"]} · {name}'
|
|
desc = p["seo_description"] or ""
|
|
trk = await _tracking_head(db)
|
|
page = (f'<!doctype html><html lang="en"><head><meta charset="utf-8">'
|
|
f'<meta name="viewport" content="width=device-width,initial-scale=1">'
|
|
f'<title>{e(title)}</title><meta name="description" content="{e(desc)}">'
|
|
f'<meta property="og:title" content="{e(title)}"><meta property="og:description" content="{e(desc)}">'
|
|
f'<style>body{{font:16px/1.6 system-ui,-apple-system,sans-serif;max-width:720px;margin:40px auto;'
|
|
f'padding:0 20px;color:#1a1a1a}}h1{{font-size:28px}}a{{color:#c0267e}}img{{max-width:100%}}</style>'
|
|
f'{trk}</head><body><p><a href="/">← {e(name)}</a></p><h1>{e(p["title"])}</h1>{p["body"]}</body></html>')
|
|
return HTMLResponse(page)
|
|
|
|
|
|
@router.get("/config")
|
|
async def shop_config(db=Depends(get_db)):
|
|
row = (await db.execute(text("SELECT config FROM store_config WHERE store_id=1"))).first()
|
|
cfg = row[0] if row else None
|
|
if isinstance(cfg, str):
|
|
cfg = json.loads(cfg)
|
|
return cfg or DEFAULTS
|
|
|
|
|
|
@router.get("/catalog")
|
|
async def shop_catalog(q: str = Query(""), page: int = Query(1, ge=1), db=Depends(get_db)):
|
|
per = 24
|
|
where = ["i.in_stock", "i.store_id = 1"]
|
|
params = {"per": per, "off": (page - 1) * per}
|
|
if q:
|
|
where.append("(coalesce(i.title, dc.title) ILIKE :q OR dc.artist ILIKE :q)")
|
|
params["q"] = f"%{q}%"
|
|
w = " AND ".join(where)
|
|
items = [dict(r) for r in (await db.execute(text(f"""
|
|
SELECT i.sku, coalesce(i.title, dc.title) AS title, dc.artist, dc.thumb,
|
|
i.price::float AS price, i.condition, i.kind
|
|
FROM inventory i LEFT JOIN disc_cache dc ON dc.release_id = i.release_id
|
|
WHERE {w}
|
|
ORDER BY i.updated_at DESC NULLS LAST
|
|
LIMIT :per OFFSET :off"""), params)).mappings()]
|
|
cparams = {k: v for k, v in params.items() if k not in ("per", "off")}
|
|
total = (await db.execute(text(
|
|
f"SELECT count(*) FROM inventory i LEFT JOIN disc_cache dc ON dc.release_id=i.release_id WHERE {w}"
|
|
), cparams)).scalar()
|
|
return {"items": items, "page": page, "per": per, "total": total}
|
|
|
|
|
|
@router.get("/artist/{artist_id}")
|
|
async def shop_artist(artist_id: int, db=Depends(get_db)):
|
|
r = (await db.execute(text("SELECT id, name, realname FROM disc_artist WHERE id=:i"),
|
|
{"i": artist_id})).mappings().first()
|
|
return {"artist": dict(r) if r else {"id": artist_id, "name": f"Artist {artist_id}"}}
|
|
|
|
|
|
@router.get("/label/{label_id}")
|
|
async def shop_label(label_id: int, db=Depends(get_db)):
|
|
r = (await db.execute(text("SELECT id, name, profile FROM disc_label WHERE id=:i"),
|
|
{"i": label_id})).mappings().first()
|
|
return {"label": dict(r) if r else {"id": label_id, "name": f"Label {label_id}"}}
|
|
|
|
|
|
@router.get("/browse")
|
|
async def shop_browse(q: str = Query(""), genre: str = Query(""), style: str = Query(""),
|
|
fmt: str = Query(""), artist: int | None = None, label: int | None = None,
|
|
year_min: int | None = None, year_max: int | None = None,
|
|
price_min: float | None = None, price_max: float | None = None,
|
|
sort: str = Query("new"), page: int = Query(1, ge=1), db=Depends(get_db)):
|
|
"""Faceted, release-grouped catalog — the public Browse page. One row per release with its cheapest copy."""
|
|
per = 24
|
|
where = ["i.in_stock", "i.store_id = 1", "i.release_id IS NOT NULL"]
|
|
params = {"per": per, "off": (page - 1) * per}
|
|
if q:
|
|
where.append("(coalesce(i.title, dr.title) ILIKE :q OR dr.artists_sort ILIKE :q)"); params["q"] = f"%{q}%"
|
|
if genre:
|
|
where.append("EXISTS (SELECT 1 FROM disc_release_genre g WHERE g.release_id=i.release_id AND g.genre_name=:genre)"); params["genre"] = genre
|
|
if style:
|
|
where.append("EXISTS (SELECT 1 FROM disc_release_style s WHERE s.release_id=i.release_id AND s.style_name=:style)"); params["style"] = style
|
|
if fmt:
|
|
where.append("EXISTS (SELECT 1 FROM disc_release_format f WHERE f.release_id=i.release_id AND f.name ILIKE :fmt)"); params["fmt"] = f"%{fmt}%"
|
|
if artist:
|
|
where.append("EXISTS (SELECT 1 FROM disc_release_artist ra WHERE ra.release_id=i.release_id AND ra.artist_id=:artist)"); params["artist"] = artist
|
|
if label:
|
|
where.append("EXISTS (SELECT 1 FROM disc_release_label rl WHERE rl.release_id=i.release_id AND rl.label_id=:label)"); params["label"] = label
|
|
if year_min:
|
|
where.append("dr.year >= :ymin"); params["ymin"] = year_min
|
|
if year_max:
|
|
where.append("dr.year <= :ymax"); params["ymax"] = year_max
|
|
if price_min:
|
|
where.append("i.price >= :pmin"); params["pmin"] = price_min
|
|
if price_max:
|
|
where.append("i.price <= :pmax"); params["pmax"] = price_max
|
|
w = " AND ".join(where)
|
|
order = {"new": "max(i.updated_at) DESC NULLS LAST", "price_asc": "min(i.price) ASC",
|
|
"price_desc": "min(i.price) DESC", "year": "dr.year DESC NULLS LAST",
|
|
"artist": "dr.artists_sort", "title": "min(coalesce(i.title, dr.title))"}.get(sort, "max(i.updated_at) DESC NULLS LAST")
|
|
items = [dict(r) for r in (await db.execute(text(f"""
|
|
SELECT i.release_id, coalesce(min(i.title), dr.title) AS title, dr.artists_sort AS artist,
|
|
dr.year, dr.country, min(i.price)::float AS price, count(*) AS copies,
|
|
(SELECT thumb FROM disc_cache dc WHERE dc.release_id=i.release_id) AS thumb,
|
|
(SELECT artist_id FROM disc_release_artist ra WHERE ra.release_id=i.release_id ORDER BY ra.position LIMIT 1) AS artist_id,
|
|
(SELECT string_agg(DISTINCT genre_name, ', ') FROM disc_release_genre g WHERE g.release_id=i.release_id) AS genre
|
|
FROM inventory i JOIN disc_release dr ON dr.id = i.release_id
|
|
WHERE {w}
|
|
GROUP BY i.release_id, dr.title, dr.artists_sort, dr.year, dr.country
|
|
ORDER BY {order} LIMIT :per OFFSET :off"""), params)).mappings()]
|
|
cp = {k: v for k, v in params.items() if k not in ("per", "off")}
|
|
total = (await db.execute(text(
|
|
f"SELECT count(DISTINCT i.release_id) FROM inventory i JOIN disc_release dr ON dr.id=i.release_id WHERE {w}"
|
|
), cp)).scalar()
|
|
return {"items": items, "page": page, "per": per, "total": total}
|
|
|
|
|
|
@router.get("/facets")
|
|
async def shop_facets(db=Depends(get_db)):
|
|
async def top(tbl, col):
|
|
return [dict(r) for r in (await db.execute(text(f"""
|
|
SELECT x.{col} AS name, count(DISTINCT i.release_id) AS n
|
|
FROM {tbl} x JOIN inventory i ON i.release_id = x.release_id
|
|
WHERE i.in_stock AND i.store_id=1 AND x.{col} IS NOT NULL AND x.{col} <> ''
|
|
GROUP BY x.{col} ORDER BY n DESC LIMIT 24"""))).mappings()]
|
|
return {"genres": await top("disc_release_genre", "genre_name"),
|
|
"styles": await top("disc_release_style", "style_name"),
|
|
"formats": await top("disc_release_format", "name")}
|
|
|
|
|
|
@router.get("/release/{release_id}")
|
|
async def shop_release(release_id: int, db=Depends(get_db)):
|
|
"""Public release detail — metadata + tracklist + in-stock copies (with the Woo buy link)."""
|
|
dr = (await db.execute(text("""
|
|
SELECT dr.id, dr.title, dr.artists_sort AS artist, dr.country, dr.year, dr.notes, dr.master_id,
|
|
(SELECT thumb FROM disc_cache dc WHERE dc.release_id=dr.id) AS thumb,
|
|
(SELECT artist_id FROM disc_release_artist ra WHERE ra.release_id=dr.id ORDER BY ra.position LIMIT 1) AS artist_id,
|
|
(SELECT string_agg(label_name || coalesce(' ('||catno||')',''), ', ') FROM disc_release_label WHERE release_id=dr.id) AS label,
|
|
(SELECT string_agg(name || coalesce(' '||descriptions,''), ', ') FROM disc_release_format WHERE release_id=dr.id) AS format,
|
|
(SELECT string_agg(DISTINCT genre_name, ', ') FROM disc_release_genre WHERE release_id=dr.id) AS genre,
|
|
(SELECT string_agg(DISTINCT style_name, ', ') FROM disc_release_style WHERE release_id=dr.id) AS style
|
|
FROM disc_release dr WHERE dr.id = :r"""), {"r": release_id})).mappings().first()
|
|
tracks = [dict(r) for r in (await db.execute(text("""
|
|
SELECT position, title, duration FROM disc_release_track WHERE release_id=:r ORDER BY sequence NULLS LAST, position
|
|
"""), {"r": release_id})).mappings()]
|
|
copies = [dict(r) for r in (await db.execute(text("""
|
|
SELECT i.sku, i.price::float AS price, i.condition, i.sleeve_cond, i.product_url,
|
|
c.label_text AS crate, r.name AS rack
|
|
FROM inventory i
|
|
LEFT JOIN virtual_crate c ON c.id = i.crate_id
|
|
LEFT JOIN virtual_rack r ON r.id = c.rack_id
|
|
WHERE i.release_id=:r AND i.in_stock AND i.store_id=1 ORDER BY i.price
|
|
"""), {"r": release_id})).mappings()]
|
|
return {"release": dict(dr) if dr else None, "tracks": tracks, "copies": copies}
|
|
|
|
|
|
def _apple_id(raw):
|
|
"""apple_id is country-scoped {"<cc>":"<album_id>"} (some rows double-JSON-encoded). Prefer AU
|
|
(this is an AU store), then US, then whatever country is present → a real music.apple.com link."""
|
|
if not raw:
|
|
return None
|
|
try:
|
|
v = json.loads(raw)
|
|
if isinstance(v, str):
|
|
v = json.loads(v)
|
|
except (ValueError, TypeError):
|
|
return None
|
|
if not isinstance(v, dict) or not v:
|
|
return None
|
|
cc = "au" if "au" in v else ("us" if "us" in v else next(iter(v)))
|
|
aid = v.get(cc)
|
|
return {"country": cc, "album_id": aid,
|
|
"url": f"https://music.apple.com/{cc}/album/{aid}" if aid else None}
|
|
|
|
|
|
@router.get("/audio/{release_id}")
|
|
async def shop_audio(release_id: int, db=Depends(get_db)):
|
|
"""Playable / embeddable previews — Apple 30s preview + country-scoped Apple link, Beatport/Bandcamp, YouTube."""
|
|
a = (await db.execute(text("""
|
|
SELECT apple_id, apple_url, apple_preview_url, beatport_id, beatport_url, beatport_embed,
|
|
beatport_genre, spotify_id, bandcamp_url, bandcamp_embed, soundcloud_url
|
|
FROM disc_release_audio WHERE release_id = :r"""), {"r": release_id})).mappings().first()
|
|
youtube = await _live_youtube(db, release_id)
|
|
if not a:
|
|
return {"apple": None, "apple_preview": None, "youtube": youtube}
|
|
pv = a["apple_preview_url"]
|
|
previews = []
|
|
if isinstance(pv, str) and pv.lstrip().startswith("["):
|
|
try:
|
|
previews = [u for u in json.loads(pv) if u]
|
|
except (ValueError, TypeError):
|
|
previews = []
|
|
elif pv:
|
|
previews = [pv]
|
|
return {"apple": _apple_id(a["apple_id"]), "apple_url": a["apple_url"],
|
|
"apple_preview": previews[0] if previews else None, "apple_previews": previews,
|
|
"beatport_url": a["beatport_url"], "beatport_embed": a["beatport_embed"],
|
|
"beatport_id": a["beatport_id"], "bandcamp_url": a["bandcamp_url"],
|
|
"bandcamp_embed": a["bandcamp_embed"], "soundcloud_url": a["soundcloud_url"],
|
|
"spotify_id": a["spotify_id"], "youtube": youtube}
|
|
|
|
|
|
async def _live_youtube(db, release_id):
|
|
"""Pick a NON-dead YouTube clip — validates unchecked ones via YouTube oembed (200=live) and caches `dead`."""
|
|
rows = [dict(r) for r in (await db.execute(text(
|
|
"SELECT DISTINCT uri, dead FROM disc_release_video WHERE release_id=:r AND uri<>'' ORDER BY uri LIMIT 8"
|
|
), {"r": release_id})).mappings()]
|
|
unchecked = [v for v in rows if v["dead"] is None][:4]
|
|
if unchecked:
|
|
async with httpx.AsyncClient(timeout=8) as c:
|
|
async def chk(v):
|
|
yid = _ytid(v["uri"])
|
|
if not yid:
|
|
return v["uri"], True
|
|
try:
|
|
rr = await c.get("https://www.youtube.com/oembed",
|
|
params={"url": "https://youtu.be/" + yid, "format": "json"})
|
|
return v["uri"], rr.status_code != 200
|
|
except Exception:
|
|
return v["uri"], None # network blip — leave unchecked for next time
|
|
checked = await asyncio.gather(*[chk(v) for v in unchecked])
|
|
wrote = False
|
|
for uri, dead in checked:
|
|
if dead is not None:
|
|
await db.execute(text("UPDATE disc_release_video SET dead=:d, checked_at=now() "
|
|
"WHERE release_id=:r AND uri=:u"), {"d": dead, "r": release_id, "u": uri})
|
|
for v in rows:
|
|
if v["uri"] == uri:
|
|
v["dead"] = dead
|
|
wrote = True
|
|
if wrote:
|
|
await db.commit()
|
|
for v in rows:
|
|
if v["dead"] is False:
|
|
yid = _ytid(v["uri"])
|
|
if yid:
|
|
return yid
|
|
return None
|
|
|
|
|
|
@router.get("/audio-tracks/{release_id}")
|
|
async def shop_audio_tracks(release_id: int, db=Depends(get_db)):
|
|
"""Per-track 30s previews — lazily resolved from each track's Apple song id via the iTunes lookup API, then cached."""
|
|
rows = [dict(r) for r in (await db.execute(text("""
|
|
SELECT sequence, position, title, apple_id, apple_preview
|
|
FROM disc_release_track WHERE release_id=:r AND apple_id ~ '^[0-9]+$'
|
|
ORDER BY sequence NULLS LAST, position"""), {"r": release_id})).mappings()]
|
|
need = [t["apple_id"] for t in rows if not t["apple_preview"]]
|
|
if need:
|
|
found = {}
|
|
try:
|
|
async with httpx.AsyncClient(timeout=15) as c:
|
|
rr = await c.get("https://itunes.apple.com/lookup",
|
|
params={"id": ",".join(need[:190]), "country": "au", "entity": "song"})
|
|
found = {str(x.get("trackId")): x.get("previewUrl")
|
|
for x in rr.json().get("results", []) if x.get("previewUrl")}
|
|
except Exception:
|
|
found = {}
|
|
for aid, prev in found.items():
|
|
await db.execute(text("UPDATE disc_release_track SET apple_preview=:p WHERE release_id=:r AND apple_id=:a"),
|
|
{"p": prev, "r": release_id, "a": aid})
|
|
if found:
|
|
await db.commit()
|
|
for t in rows:
|
|
if not t["apple_preview"]:
|
|
t["apple_preview"] = found.get(t["apple_id"])
|
|
return {"tracks": [{"position": t["position"], "title": t["title"], "preview": t["apple_preview"]}
|
|
for t in rows if t["apple_preview"]]}
|
|
|
|
|
|
class WantIn(BaseModel):
|
|
email: str
|
|
artist: str = ""
|
|
title: str = ""
|
|
name: str = ""
|
|
phone: str = ""
|
|
format: str = ""
|
|
max_price: float | None = None
|
|
delivery_preference: str = "either"
|
|
postcode: str = ""
|
|
notes: str = ""
|
|
release_id: int | None = None
|
|
|
|
|
|
@router.post("/wantlist")
|
|
async def shop_wantlist(body: WantIn, db=Depends(get_db)):
|
|
"""Public 'request a record we don't stock' intake — no auth, keyed by email (guest or known customer)."""
|
|
if "@" not in body.email or not (body.artist.strip() or body.title.strip()):
|
|
raise HTTPException(422, "email and an artist or title are required")
|
|
pref = body.delivery_preference if body.delivery_preference in ("pickup", "post", "either") else "either"
|
|
rid = (await db.execute(text("""
|
|
INSERT INTO wantlist (release_id, artist, title, name, phone, email, format, max_price,
|
|
delivery_preference, postcode, notes)
|
|
VALUES (:release_id, :artist, :title, :name, :phone, :email, :format, :max_price, :pref, :postcode, :notes)
|
|
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 CheckoutItem(BaseModel):
|
|
sku: str
|
|
qty: int = 1
|
|
|
|
|
|
class CheckoutIn(BaseModel):
|
|
items: list[CheckoutItem]
|
|
provider: str = "mock"
|
|
token: str # tokenised card from the frontend SDK (never raw card data)
|
|
email: str | None = None
|
|
name: str | None = None
|
|
pickup: bool = False # skip postage
|
|
|
|
|
|
@router.post("/checkout")
|
|
async def checkout(body: CheckoutIn, db=Depends(get_db)):
|
|
"""Online checkout — the WooCommerce replacement. SERVER-SIDE priced (never trusts the client), GST-inclusive
|
|
(AU), cheapest AusPost postage, charged via ANY configured processor, writes the order + marks stock sold."""
|
|
from . import payments
|
|
if not body.items:
|
|
raise HTTPException(400, "empty cart")
|
|
if body.provider not in await payments.enabled_providers(db):
|
|
raise HTTPException(403, f"payment provider '{body.provider}' is not available")
|
|
rows = {r["sku"]: dict(r) for r in (await db.execute(text("""
|
|
SELECT i.sku, coalesce(i.title, dc.title) AS title, i.price::float AS price, i.in_stock,
|
|
i.weight_g::float AS weight_g, i.release_id
|
|
FROM inventory i LEFT JOIN disc_cache dc ON dc.release_id = i.release_id
|
|
WHERE i.sku = ANY(:skus) AND i.store_id = 1"""), {"skus": [i.sku for i in body.items]})).mappings()}
|
|
lines, subtotal, weight = [], 0.0, 0.0
|
|
for it in body.items:
|
|
r = rows.get(it.sku)
|
|
if not r:
|
|
raise HTTPException(400, f"unknown item {it.sku}")
|
|
if not r["in_stock"]:
|
|
raise HTTPException(409, f"{r.get('title') or it.sku} is already sold")
|
|
qty, price = max(1, it.qty), float(r["price"] or 0)
|
|
subtotal += price * qty
|
|
weight += (r["weight_g"] or 280) * qty
|
|
lines.append({"sku": it.sku, "title": r.get("title"), "release_id": r.get("release_id"), "qty": qty, "price": price})
|
|
shipping = 0.0
|
|
if not body.pickup:
|
|
wg = int(weight) + 50 * len(lines) # +packaging
|
|
parcels = max(1, -(-wg // 5000)); per = -(-wg // parcels)
|
|
cheapest = ((await db.execute(text(
|
|
"SELECT min(price)::float AS p FROM post_flat_rate WHERE :w BETWEEN weight_min_g AND weight_max_g"),
|
|
{"w": per})).mappings().first() or {}).get("p")
|
|
shipping = round((cheapest or 0) * parcels, 2)
|
|
total = round(subtotal + shipping, 2)
|
|
gst = round(total / 11, 2) # AU GST-inclusive component (for the order record)
|
|
ref = "WEB" + datetime.now().strftime("%y%m%d%H%M%S")
|
|
pay = await payments.charge(db, body.provider, body.token, int(round(total * 100)), "AUD", ref)
|
|
if not pay.get("ok"):
|
|
return {"ok": False, "stage": "payment", "error": pay.get("error")}
|
|
sn = "W" + datetime.now().strftime("%y%m%d") + "-" + secrets.token_hex(2).upper()
|
|
sale_id = (await db.execute(text("""
|
|
INSERT INTO sales (sale_number, subtotal, discount_amount, tax_amount, total, status, payment_method,
|
|
payment_status, amount_paid, notes, sale_date, created_at)
|
|
VALUES (:sn, :sub, 0, :tax, :total, 'publish', :pm, 'paid', :total, :notes, now(), now()) RETURNING id"""),
|
|
{"sn": sn, "sub": round(subtotal, 2), "tax": gst, "total": total, "pm": pay["provider"],
|
|
"notes": f"online · pay {pay.get('payment_id')} · ship ${shipping}" + (f" · {body.email}" if body.email else "")})).first()[0]
|
|
for li in lines:
|
|
await db.execute(text("""INSERT INTO sale_items (sale_id, sku, release_id, item_name, qty, unit_price, line_total)
|
|
VALUES (:s,:sku,:rid,:nm,:q,:up,:lt)"""),
|
|
{"s": sale_id, "sku": li["sku"], "rid": li["release_id"], "nm": li["title"],
|
|
"q": li["qty"], "up": li["price"], "lt": round(li["price"] * li["qty"], 2)})
|
|
await db.execute(text("UPDATE inventory SET in_stock=false, status='sold', sold_date=now() WHERE sku=:sku AND store_id=1"),
|
|
{"sku": li["sku"]})
|
|
await db.commit()
|
|
try: # anti-oversell: delist these from Discogs now they've sold online (best-effort)
|
|
from . import discogs_mp
|
|
await discogs_mp.delist_skus(db, [li["sku"] for li in lines])
|
|
except Exception:
|
|
pass
|
|
return {"ok": True, "order": sn, "sale_id": sale_id, "subtotal": round(subtotal, 2),
|
|
"shipping": shipping, "gst": gst, "total": total,
|
|
"payment": {"provider": pay["provider"], "id": pay.get("payment_id")}}
|
|
|
|
|
|
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}
|