Compare commits

..

No commits in common. "00e08a86dda8d2e9f71421c386c42a708fa5d308" and "cfd6b547319903b7bdd38ad4fd01d31cb1628827" have entirely different histories.

7 changed files with 34 additions and 122 deletions

View File

@ -12,6 +12,3 @@ ingest_raw/
rarw*.txt rarw*.txt
inertia.txt inertia.txt
*.xlsx *.xlsx
# SQL dumps — customer/order PII + WP password hashes. Never bake into the image;
# the app's startup DDL is inline in app/main.py, so no .sql is needed at runtime.
*.sql

1
.gitignore vendored
View File

@ -12,5 +12,4 @@ inertia.txt
# live-site MariaDB dumps for 3D-store/editor data work — full site export, carries customer/ # live-site MariaDB dumps for 3D-store/editor data work — full site export, carries customer/
# order PII. Local-only; another lane consumes it for robotmonster.party/store racks + editor. # order PII. Local-only; another lane consumes it for robotmonster.party/store racks + editor.
site-update.sql site-update.sql
customers*.sql
*-dump.sql *-dump.sql

View File

@ -1,6 +1,5 @@
import re import re
import secrets import secrets
from datetime import datetime, timedelta
import httpx import httpx
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
@ -548,24 +547,9 @@ class BulkIn(BaseModel):
value: float | int | str | None = None value: float | int | str | None = None
async def _fresh_sku(db):
"""Canonical SKU = 14-digit product-added timestamp (YYYYMMDDHHMMSS) — the format SCANGOD's EPC/RFID
codec requires (packs SKU 14 digits × 10^9 + release_id onto the UHF chip). Bumps a second on
collision so a same-second batch stays unique against the sku PRIMARY KEY.
ponytail: check-then-insert; a truly concurrent same-second add on another connection could still hit
the PK and be ON CONFLICT DO NOTHING'd. Fine for counter/admin stock entry; add retry-on-conflict if
bulk-import concurrency grows."""
base = datetime.now()
for i in range(120):
sku = (base + timedelta(seconds=i)).strftime("%Y%m%d%H%M%S")
if not (await db.execute(text("SELECT 1 FROM inventory WHERE sku = :s"), {"s": sku})).first():
return sku
return (base + timedelta(seconds=120)).strftime("%Y%m%d%H%M%S")
@router.post("/inventory/add") @router.post("/inventory/add")
async def inv_add(body: InvItemIn, ident=Depends(require_token), db=Depends(get_db)): async def inv_add(body: InvItemIn, ident=Depends(require_token), db=Depends(get_db)):
sku = (body.sku or "").strip() or await _fresh_sku(db) sku = (body.sku or "").strip() or ("RG" + secrets.token_hex(4).upper())
title = body.title title = body.title
if body.release_id and not title: # pull the cached title if intaking by release if body.release_id and not title: # pull the cached title if intaking by release
r = (await db.execute(text("SELECT title FROM disc_cache WHERE release_id=:r"), r = (await db.execute(text("SELECT title FROM disc_cache WHERE release_id=:r"),
@ -645,7 +629,7 @@ async def inv_import(body: ImportIn, ident=Depends(require_token), db=Depends(ge
if not rid: if not rid:
not_found.append(ln) not_found.append(ln)
continue continue
sku = await _fresh_sku(db) sku = "RG" + secrets.token_hex(4).upper()
await db.execute(text(""" await db.execute(text("""
INSERT INTO inventory (sku, store_id, kind, release_id, title, price, qty, in_stock, condition, INSERT INTO inventory (sku, store_id, kind, release_id, title, price, qty, in_stock, condition,
crate_id, status, created_at, updated_at) crate_id, status, created_at, updated_at)

View File

@ -10,7 +10,6 @@ from sqlalchemy import text
from .auth import require_token from .auth import require_token
from .db import get_db from .db import get_db
from .urlguard import safe_public_url
# Storefront customizer — per-store theme + header menu + which elements appear on cards / # Storefront customizer — per-store theme + header menu + which elements appear on cards /
# product pages, in what order. Lets RecordGod dress ANY shop without touching code. # product pages, in what order. Lets RecordGod dress ANY shop without touching code.
@ -72,12 +71,7 @@ async def import_brand(body: UrlIn, ident=Depends(require_token)):
if not url.startswith("http"): if not url.startswith("http"):
url = "https://" + url url = "https://" + url
try: try:
url = safe_public_url(url) async with httpx.AsyncClient(timeout=15, follow_redirects=True,
except ValueError as e:
raise HTTPException(400, f"refused url: {e}")
# ponytail: DNS-rebinding TOCTOU remains; follow_redirects=False closes the redirect bypass
try:
async with httpx.AsyncClient(timeout=15, follow_redirects=False,
headers={"User-Agent": "RecordGod/0.1 brand-import"}) as c: headers={"User-Agent": "RecordGod/0.1 brand-import"}) as c:
html = (await c.get(url)).text html = (await c.get(url)).text
except Exception as e: except Exception as e:

View File

@ -74,8 +74,6 @@ async def create_sale(body: SaleIn, ident=Depends(require_token), db=Depends(get
raise HTTPException(400, "no items") raise HTTPException(400, "no items")
gross = sum(li.unit_price * li.qty for li in body.items) gross = sum(li.unit_price * li.qty for li in body.items)
line_disc = sum(li.discount for li in body.items) line_disc = sum(li.discount for li in body.items)
if (line_disc + body.cart_discount) > 0.001 and ident.get("role") != "admin":
raise HTTPException(403, "discounts need a manager")
sub_after = gross - line_disc - body.cart_discount sub_after = gross - line_disc - body.cart_discount
tax = round(sub_after * body.tax_rate / 100, 2) tax = round(sub_after * body.tax_rate / 100, 2)
total = round(sub_after + tax - body.trade_in, 2) total = round(sub_after + tax - body.trade_in, 2)
@ -87,26 +85,6 @@ async def create_sale(body: SaleIn, ident=Depends(require_token), db=Depends(get
hold_due = body.hold.get("due_date") if is_hold else None hold_due = body.hold.get("due_date") if is_hold else None
sale_number = "S" + datetime.now().strftime("%y%m%d") + "-" + secrets.token_hex(2).upper() sale_number = "S" + datetime.now().strftime("%y%m%d") + "-" + secrets.token_hex(2).upper()
# CLAIM each copy off the floor (sold, or reserved on a hold) atomically BEFORE booking the sale —
# one statement per sku so a copy that's already gone can't be double-sold. A held copy the counter is
# fulfilling is off the floor already (in_stock=false, status='held') so the guard also matches 'held'.
ist = "held" if is_hold else "sold"
claimed = []
for li in body.items:
got = (await db.execute(text("""
UPDATE inventory SET in_stock = false, status = :ist,
sold_date = CASE WHEN :ist = 'sold' THEN now() ELSE NULL END
WHERE sku = :sku AND store_id = 1 AND (in_stock OR status = 'held') RETURNING sku
"""), {"ist": ist, "sku": li.sku})).first()
if got:
claimed.append(li.sku)
else: # already sold — release our partial claim, bail
if claimed:
await db.execute(text("UPDATE inventory SET in_stock = true, status = 'publish', sold_date = NULL "
"WHERE sku = ANY(:claimed) AND store_id = 1"), {"claimed": claimed})
await db.commit()
raise HTTPException(409, f"{li.sku} just sold")
cust = body.customer_id if body.customer_id else None # don't FK-store the synthetic guest (0) cust = body.customer_id if body.customer_id else None # don't FK-store the synthetic guest (0)
sale_id = (await db.execute(text(""" sale_id = (await db.execute(text("""
INSERT INTO sales (sale_number, customer_id, subtotal, discount_amount, tax_amount, total, INSERT INTO sales (sale_number, customer_id, subtotal, discount_amount, tax_amount, total,
@ -128,6 +106,13 @@ async def create_sale(body: SaleIn, ident=Depends(require_token), db=Depends(get
"""), {"sid": sale_id, "sku": li.sku, "name": li.title or li.sku, "qty": li.qty, """), {"sid": sale_id, "sku": li.sku, "name": li.title or li.sku, "qty": li.qty,
"up": li.unit_price, "lt": li.unit_price * li.qty - li.discount, "up": li.unit_price, "lt": li.unit_price * li.qty - li.discount,
"disc": li.discount, "op": li.unit_price}) "disc": li.discount, "op": li.unit_price})
# take the items off the floor (sold, or reserved on a hold) so they can't be double-sold
await db.execute(text("""
UPDATE inventory SET in_stock = false, status = :ist,
sold_date = CASE WHEN :ist = 'sold' THEN now() ELSE NULL END
WHERE sku = ANY(:skus) AND store_id = 1
"""), {"ist": "held" if is_hold else "sold", "skus": [li.sku for li in body.items]})
await db.commit() await db.commit()
if not is_hold: # cross-channel anti-oversell: delist anything that just sold from Discogs (best-effort) if not is_hold: # cross-channel anti-oversell: delist anything that just sold from Discogs (best-effort)
try: try:
@ -225,20 +210,15 @@ async def email_test(body: EmailIn, ident=Depends(require_token), db=Depends(get
@router.post("/{sale_id}/pay") @router.post("/{sale_id}/pay")
async def take_payment(sale_id: int, body: PayIn, ident=Depends(require_token), db=Depends(get_db)): async def take_payment(sale_id: int, body: PayIn, ident=Depends(require_token), db=Depends(get_db)):
"""Payment toward a held / payment-plan sale. Fully paid → completes it + sells the items.""" """Payment toward a held / payment-plan sale. Fully paid → completes it + sells the items."""
if not (await db.execute(text("SELECT 1 FROM sales WHERE id=:i"), {"i": sale_id})).first(): s = (await db.execute(text("SELECT total::float AS total, coalesce(amount_paid,0)::float AS paid FROM sales WHERE id=:i"),
{"i": sale_id})).mappings().first()
if not s:
raise HTTPException(404, "not found") raise HTTPException(404, "not found")
if body.amount <= 0: new_paid = round(s["paid"] + body.amount, 2)
raise HTTPException(422, "amount must be positive") done = new_paid >= s["total"] - 0.005
# atomic increment so concurrent layby payments sum instead of clobbering each other
r = (await db.execute(text(
"UPDATE sales SET amount_paid = coalesce(amount_paid,0) + :amt WHERE id=:i "
"RETURNING amount_paid::float AS paid, total::float AS total"),
{"amt": body.amount, "i": sale_id})).mappings().first()
new_paid = round(r["paid"], 2)
done = new_paid >= r["total"] - 0.005
await db.execute(text( await db.execute(text(
"UPDATE sales SET payment_status=:ps, status=:st WHERE id=:i"), "UPDATE sales SET amount_paid=:p, payment_status=:ps, status=:st WHERE id=:i"),
{"ps": "paid" if done else "hold", {"p": new_paid, "ps": "paid" if done else "hold",
"st": "completed" if done else "held", "i": sale_id}) "st": "completed" if done else "held", "i": sale_id})
if done: if done:
await db.execute(text(""" await db.execute(text("""
@ -246,7 +226,7 @@ async def take_payment(sale_id: int, body: PayIn, ident=Depends(require_token),
WHERE sku IN (SELECT sku FROM sale_items WHERE sale_id=:i) AND store_id = 1 WHERE sku IN (SELECT sku FROM sale_items WHERE sale_id=:i) AND store_id = 1
"""), {"i": sale_id}) """), {"i": sale_id})
await db.commit() await db.commit()
return {"ok": True, "amount_paid": new_paid, "balance": round(r["total"] - new_paid, 2), "completed": done} return {"ok": True, "amount_paid": new_paid, "balance": round(s["total"] - new_paid, 2), "completed": done}
# ── Customers ──────────────────────────────────────────────────────────────────────────────── # ── Customers ────────────────────────────────────────────────────────────────────────────────

View File

@ -1,7 +1,6 @@
import asyncio import asyncio
import html import html
import json import json
import logging
import re import re
import secrets import secrets
from datetime import datetime from datetime import datetime
@ -31,7 +30,6 @@ from .layout_routes import DEFAULTS
# PUBLIC storefront API — the customer-facing shop reads its theme/layout + catalog here. # 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. # No auth (theme + in-stock catalog aren't secret); driven by what the builder saves.
router = APIRouter(prefix="/shop", tags=["shop"]) router = APIRouter(prefix="/shop", tags=["shop"])
log = logging.getLogger("recordgod")
async def _tracking_head(db) -> str: async def _tracking_head(db) -> str:
@ -431,7 +429,7 @@ async def checkout(body: CheckoutIn, db=Depends(get_db)):
raise HTTPException(400, f"unknown item {it.sku}") raise HTTPException(400, f"unknown item {it.sku}")
if not r["in_stock"]: if not r["in_stock"]:
raise HTTPException(409, f"{r.get('title') or it.sku} is already sold") raise HTTPException(409, f"{r.get('title') or it.sku} is already sold")
qty, price = 1, float(r["price"] or 0) # ponytail: one-row-per-copy — a sku IS one physical item; qty>1 can't be honoured qty, price = max(1, it.qty), float(r["price"] or 0)
subtotal += price * qty subtotal += price * qty
weight += (r["weight_g"] or 280) * 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}) lines.append({"sku": it.sku, "title": r.get("title"), "release_id": r.get("release_id"), "qty": qty, "price": price})
@ -446,48 +444,24 @@ async def checkout(body: CheckoutIn, db=Depends(get_db)):
total = round(subtotal + shipping, 2) total = round(subtotal + shipping, 2)
gst = round(total / 11, 2) # AU GST-inclusive component (for the order record) gst = round(total / 11, 2) # AU GST-inclusive component (for the order record)
ref = "WEB" + datetime.now().strftime("%y%m%d%H%M%S") ref = "WEB" + datetime.now().strftime("%y%m%d%H%M%S")
# CLAIM stock atomically BEFORE charging — flip each copy sold in one statement so two concurrent
# buyers of a unique copy can't both charge. A missed claim releases the ones we already took.
# ponytail: claim-then-charge; a crash between claim and charge strands the copy sold with no sale — manual release, no sweeper. Add a sweeper if crashes get common.
claimed = []
for li in lines:
got = (await db.execute(text(
"UPDATE inventory SET in_stock=false, status='sold', sold_date=now() "
"WHERE sku=:sku AND store_id=1 AND in_stock RETURNING sku"), {"sku": li["sku"]})).first()
if got:
claimed.append(li["sku"])
else: # someone just bought it — release our partial claim, bail
if claimed:
await db.execute(text("UPDATE inventory SET in_stock=true, status='publish', sold_date=NULL "
"WHERE sku=ANY(:claimed) AND store_id=1"), {"claimed": claimed})
await db.commit()
raise HTTPException(409, f"{li['sku']} just sold")
await db.commit() # claims are locked in before any money moves
pay = await payments.charge(db, body.provider, body.token, int(round(total * 100)), "AUD", ref) pay = await payments.charge(db, body.provider, body.token, int(round(total * 100)), "AUD", ref)
if not pay.get("ok"): if not pay.get("ok"):
await db.execute(text("UPDATE inventory SET in_stock=true, status='publish', sold_date=NULL "
"WHERE sku=ANY(:claimed) AND store_id=1"), {"claimed": claimed}) # decline → back on the floor
await db.commit()
return {"ok": False, "stage": "payment", "error": pay.get("error")} return {"ok": False, "stage": "payment", "error": pay.get("error")}
try: # money has left the customer — if we can't record the order, log for manual refund/reconciliation sn = "W" + datetime.now().strftime("%y%m%d") + "-" + secrets.token_hex(2).upper()
sn = "W" + datetime.now().strftime("%y%m%d") + "-" + secrets.token_hex(2).upper() sale_id = (await db.execute(text("""
sale_id = (await db.execute(text(""" INSERT INTO sales (sale_number, subtotal, discount_amount, tax_amount, total, status, payment_method,
INSERT INTO sales (sale_number, subtotal, discount_amount, tax_amount, total, status, payment_method, payment_status, amount_paid, notes, sale_date, created_at)
payment_status, amount_paid, notes, sale_date, created_at) VALUES (:sn, :sub, 0, :tax, :total, 'publish', :pm, 'paid', :total, :notes, now(), now()) RETURNING id"""),
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"],
{"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]
"notes": f"online · pay {pay.get('payment_id')} · ship ${shipping}" + (f" · {body.email}" if body.email else "")})).first()[0] for li in lines:
for li in lines: await db.execute(text("""INSERT INTO sale_items (sale_id, sku, release_id, item_name, qty, unit_price, line_total)
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)"""),
VALUES (:s,:sku,:rid,:nm,:q,:up,:lt)"""), {"s": sale_id, "sku": li["sku"], "rid": li["release_id"], "nm": li["title"],
{"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)})
"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"),
# stock already claimed sold above (atomic claim before the charge) {"sku": li["sku"]})
await db.commit() await db.commit()
except Exception:
log.error("checkout: PAYMENT TAKEN but order write failed — manual refund/reconcile. "
"payment_id=%s provider=%s ref=%s", pay.get("payment_id"), pay.get("provider"), ref)
raise
try: # anti-oversell: delist these from Discogs now they've sold online (best-effort) try: # anti-oversell: delist these from Discogs now they've sold online (best-effort)
from . import discogs_mp from . import discogs_mp
await discogs_mp.delist_skus(db, [li["sku"] for li in lines]) await discogs_mp.delist_skus(db, [li["sku"] for li in lines])

View File

@ -1,16 +0,0 @@
import ipaddress
import socket
from urllib.parse import urlparse
def safe_public_url(url):
"""Resolve url's host and reject any that maps to a non-public IP (SSRF guard)."""
p = urlparse(url)
if p.scheme not in ("http", "https") or not p.hostname:
raise ValueError("url must be http(s) with a hostname")
for info in socket.getaddrinfo(p.hostname, None):
ip = ipaddress.ip_address(info[4][0])
if (ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved
or ip.is_multicast or ip.is_unspecified):
raise ValueError(f"host resolves to non-public ip {ip}")
return url