money: atomic stock claims, amount validation, discount gate (converge with StoreGod twin)
R1 checkout qty clamp to one-row-per-copy; R2 claim-then-charge in online checkout with release-on-decline and post-charge write guard; R3 POS create_sale per-sku atomic claim (matches held copies so hold->counter completion still works) before booking the sale; R4 take_payment positive-amount check + atomic amount_paid increment; R5 POS discount gate (admin only, RecordGod role idiom); R6 log + re-raise if the order write fails after a successful charge. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
8535389cf1
commit
61601f1067
@ -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 ────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@ -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])
|
||||
|
||||
Loading…
Reference in New Issue
Block a user