diff --git a/app/sales_routes.py b/app/sales_routes.py index b483682..d0ecf27 100644 --- a/app/sales_routes.py +++ b/app/sales_routes.py @@ -74,6 +74,8 @@ async def create_sale(body: SaleIn, ident=Depends(require_token), db=Depends(get raise HTTPException(400, "no items") gross = sum(li.unit_price * li.qty 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 tax = round(sub_after * body.tax_rate / 100, 2) total = round(sub_after + tax - body.trade_in, 2) @@ -85,6 +87,26 @@ 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 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) sale_id = (await db.execute(text(""" INSERT INTO sales (sale_number, customer_id, subtotal, discount_amount, tax_amount, total, @@ -106,13 +128,6 @@ 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, "up": li.unit_price, "lt": li.unit_price * li.qty - li.discount, "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() if not is_hold: # cross-channel anti-oversell: delist anything that just sold from Discogs (best-effort) try: @@ -210,15 +225,20 @@ async def email_test(body: EmailIn, ident=Depends(require_token), db=Depends(get @router.post("/{sale_id}/pay") 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.""" - 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: + if not (await db.execute(text("SELECT 1 FROM sales WHERE id=:i"), {"i": sale_id})).first(): raise HTTPException(404, "not found") - new_paid = round(s["paid"] + body.amount, 2) - done = new_paid >= s["total"] - 0.005 + if body.amount <= 0: + raise HTTPException(422, "amount must be positive") + # 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( - "UPDATE sales SET amount_paid=:p, payment_status=:ps, status=:st WHERE id=:i"), - {"p": new_paid, "ps": "paid" if done else "hold", + "UPDATE sales SET payment_status=:ps, status=:st WHERE id=:i"), + {"ps": "paid" if done else "hold", "st": "completed" if done else "held", "i": sale_id}) if done: await db.execute(text(""" @@ -226,7 +246,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 """), {"i": sale_id}) await db.commit() - return {"ok": True, "amount_paid": new_paid, "balance": round(s["total"] - new_paid, 2), "completed": done} + return {"ok": True, "amount_paid": new_paid, "balance": round(r["total"] - new_paid, 2), "completed": done} # ── Customers ──────────────────────────────────────────────────────────────────────────────── diff --git a/app/shop_routes.py b/app/shop_routes.py index 6541009..39a6ae8 100644 --- a/app/shop_routes.py +++ b/app/shop_routes.py @@ -1,6 +1,7 @@ import asyncio import html import json +import logging import re import secrets from datetime import datetime @@ -30,6 +31,7 @@ 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"]) +log = logging.getLogger("recordgod") async def _tracking_head(db) -> str: @@ -429,7 +431,7 @@ async def checkout(body: CheckoutIn, db=Depends(get_db)): 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) + 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 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}) @@ -444,24 +446,48 @@ async def checkout(body: CheckoutIn, db=Depends(get_db)): 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") + # 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) 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")} - 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: # 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() + 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)}) + # stock already claimed sold above (atomic claim before the charge) + 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) from . import discogs_mp await discogs_mp.delist_skus(db, [li["sku"] for li in lines])