RECORDGOD/app/sales_routes.py
type-two 61601f1067 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>
2026-07-05 03:02:30 +10:00

444 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import json
import secrets
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy import text
from . import mailer, receipts, square
from .auth import require_token
from .db import get_db
# Point-of-sale — ring up sales, line/cart discounts, split tender, payment plans (holds),
# trade-in credit. Ported from WowPlatter's sales engine onto RecordGod's own tables.
router = APIRouter(prefix="/sales", tags=["sales"])
class LineIn(BaseModel):
sku: str
title: str | None = None
qty: int = 1
unit_price: float
discount: float = 0
class SaleIn(BaseModel):
items: list[LineIn]
cart_discount: float = 0
tax_rate: float = 0
payment_method: str = "cash"
split: list[dict] | None = None
hold: dict | None = None
trade_in: float = 0
tendered: float | None = None # cash given (for the change line on the receipt)
notes: str | None = None
customer_id: int | None = None # 0 = guest sale; None when unset
class PayIn(BaseModel):
amount: float
method: str = "cash"
class CustomerIn(BaseModel):
first_name: str
last_name: str | None = ""
email: str | None = None
phone: str | None = None
address: str | None = None
@router.get("/search")
async def search_items(q: str = Query(""), ident=Depends(require_token), db=Depends(get_db)):
"""Find in-stock items to ring up — title/artist/sku/barcode. The counter's search bar."""
if not q.strip():
return {"items": []}
rows = await db.execute(text("""
SELECT i.sku, coalesce(i.title, dc.title) AS title, dc.artist, dc.thumb,
i.price::float AS price, i.condition, i.crate_id, c.label_text AS crate
FROM inventory i
LEFT JOIN disc_cache dc ON dc.release_id = i.release_id
LEFT JOIN crate c ON c.id = i.crate_id
WHERE i.store_id = 1 AND i.in_stock
AND (i.sku ILIKE :q OR i.identifier = :raw
OR coalesce(i.title, dc.title) ILIKE :q OR dc.artist ILIKE :q)
ORDER BY i.updated_at DESC NULLS LAST LIMIT 25
"""), {"q": f"%{q.strip()}%", "raw": q.strip()})
return {"items": [dict(r) for r in rows.mappings()]}
@router.post("")
async def create_sale(body: SaleIn, ident=Depends(require_token), db=Depends(get_db)):
if not body.items:
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)
is_hold = bool(body.hold)
deposit = float(body.hold.get("deposit", 0)) if is_hold else 0
amount_paid = deposit if is_hold else total
status, pay_status = ("held", "hold") if is_hold else ("completed", "paid")
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,
status, payment_method, payment_status, amount_paid, amount_tendered, trade_in_credit,
notes, hold_expires_at, split_payments, sale_date, created_at)
VALUES (:sn, :cust, :sub, :disc, :tax, :total, :st, :pm, :ps, :paid, :tend, :trade,
:notes, :due, CAST(:split AS jsonb), now(), now())
RETURNING id
"""), {"sn": sale_number, "cust": cust, "sub": gross - line_disc, "disc": line_disc + body.cart_discount,
"tax": tax, "total": total, "st": status, "pm": body.payment_method, "ps": pay_status,
"paid": amount_paid, "tend": body.tendered, "trade": body.trade_in, "notes": body.notes,
"due": hold_due, "split": json.dumps(body.split) if body.split else None})).first()[0]
for li in body.items:
await db.execute(text("""
INSERT INTO sale_items (sale_id, sku, item_name, qty, unit_price, line_total,
discount_amount, original_price)
VALUES (:sid, :sku, :name, :qty, :up, :lt, :disc, :op)
"""), {"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})
await db.commit()
if not is_hold: # cross-channel anti-oversell: delist anything that just sold from Discogs (best-effort)
try:
from . import discogs_mp
await discogs_mp.delist_skus(db, [li.sku for li in body.items])
except Exception:
pass
return {"ok": True, "sale_id": sale_id, "sale_number": sale_number, "total": total,
"amount_paid": amount_paid, "balance": round(total - amount_paid, 2), "status": status}
@router.get("")
async def list_sales(status: str = Query(""), ident=Depends(require_token), db=Depends(get_db)):
where, params = "", {}
if status == "holds":
where = "WHERE payment_status = 'hold'"
elif status:
where, params = "WHERE status = :st", {"st": status}
rows = await db.execute(text(f"""
SELECT id, sale_number, total::float AS total, coalesce(amount_paid,0)::float AS amount_paid,
(total - coalesce(amount_paid,0))::float AS balance, status, payment_status,
payment_method, hold_expires_at, sale_date
FROM sales {where} ORDER BY sale_date DESC NULLS LAST LIMIT 60
"""), params)
return {"sales": [dict(r) for r in rows.mappings()]}
async def _load_sale(db, sale_id):
s = (await db.execute(text("SELECT * FROM sales WHERE id=:i"), {"i": sale_id})).mappings().first()
if not s:
raise HTTPException(404, "not found")
s = dict(s)
items = [dict(r) for r in (await db.execute(text(
"SELECT sku, item_name, qty, unit_price::float AS unit_price, line_total::float AS line_total, "
"discount_amount::float AS discount FROM sale_items WHERE sale_id=:i"), {"i": sale_id})).mappings()]
cust = None
if s.get("customer_id"):
c = (await db.execute(text(
"SELECT trim(first_name||' '||coalesce(last_name,'')) AS name, email FROM customer WHERE id=:i"),
{"i": s["customer_id"]})).mappings().first()
cust = dict(c) if c else None
return s, items, cust
@router.get("/{sale_id}/receipt")
async def sale_receipt(sale_id: int, ident=Depends(require_token), db=Depends(get_db)):
"""Rendered receipt HTML (for the print window) + the customer's email if on file."""
s, items, cust = await _load_sale(db, sale_id)
html = receipts.render_receipt_html(s, items, cust, await _settings(db))
return {"html": html, "sale_number": s.get("sale_number"),
"customer_email": (cust or {}).get("email")}
class EmailIn(BaseModel):
email: str
@router.post("/{sale_id}/email")
async def email_receipt(sale_id: int, body: EmailIn, ident=Depends(require_token), db=Depends(get_db)):
if "@" not in body.email:
raise HTTPException(422, "invalid email")
s, items, cust = await _load_sale(db, sale_id)
st = await _settings(db)
html = receipts.render_receipt_html(s, items, cust, st)
subject = f"{st['store_name']} receipt — {s.get('sale_number', '')}"
try:
sender = await mailer.send_mail(db, body.email, subject, html)
except mailer.MailUnconfigured as e:
raise HTTPException(503, str(e))
except Exception as e:
raise HTTPException(502, f"send failed: {e}")
return {"ok": True, "sent_to": body.email, "from": sender}
@router.post("/email-test")
async def email_test(body: EmailIn, ident=Depends(require_token), db=Depends(get_db)):
"""Send a sample receipt to verify the SMTP setup from the Settings tab."""
if "@" not in body.email:
raise HTTPException(422, "invalid email")
st = await _settings(db)
sample = {"sale_number": "TEST-0001", "sale_date": datetime.now().isoformat(),
"subtotal": 28.0, "discount_amount": 3.0, "tax_amount": 0, "total": 25.0,
"payment_method": "cash", "amount_paid": 25.0, "amount_tendered": 30.0}
items = [{"qty": 1, "item_name": "Selected Ambient Works (test)", "line_total": 28.0, "discount": 3.0}]
html = receipts.render_receipt_html(sample, items, {"name": "Test"}, st)
try:
sender = await mailer.send_mail(db, body.email, f"{st['store_name']} — test receipt", html)
except mailer.MailUnconfigured as e:
raise HTTPException(503, str(e))
except Exception as e:
raise HTTPException(502, f"send failed: {e}")
return {"ok": True, "sent_to": body.email, "from": sender}
@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."""
if not (await db.execute(text("SELECT 1 FROM sales WHERE id=:i"), {"i": sale_id})).first():
raise HTTPException(404, "not found")
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 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("""
UPDATE inventory SET status='sold', sold_date=now()
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(r["total"] - new_paid, 2), "completed": done}
# ── Customers ────────────────────────────────────────────────────────────────────────────────
@router.get("/customers")
async def list_customers(q: str = Query(""), ident=Depends(require_token), db=Depends(get_db)):
where, params = "", {}
if q.strip():
where = ("WHERE first_name ILIKE :q OR last_name ILIKE :q OR email ILIKE :q "
"OR (first_name || ' ' || coalesce(last_name,'')) ILIKE :q")
params = {"q": f"%{q.strip()}%"}
rows = await db.execute(text(f"""
SELECT id, trim(first_name || ' ' || coalesce(last_name,'')) AS name, email, phone, is_guest
FROM customer {where} ORDER BY is_guest DESC, first_name, last_name LIMIT 300
"""), params)
return {"customers": [dict(r) for r in rows.mappings()]}
@router.post("/customers")
async def add_customer(body: CustomerIn, ident=Depends(require_token), db=Depends(get_db)):
if not body.first_name.strip():
raise HTTPException(400, "first name required")
row = (await db.execute(text("""
INSERT INTO customer (first_name, last_name, email, phone, address, is_guest)
VALUES (:fn, :ln, :em, :ph, :ad, false) RETURNING id
"""), {"fn": body.first_name.strip(), "ln": (body.last_name or "").strip(),
"em": body.email, "ph": body.phone, "ad": body.address})).first()
await db.commit()
return {"ok": True, "id": row[0], "name": f"{body.first_name} {body.last_name or ''}".strip()}
# ── Discounts / promotions ───────────────────────────────────────────────────────────────────
@router.get("/discounts")
async def list_discounts(active_only: bool = Query(True), ident=Depends(require_token), db=Depends(get_db)):
where = "WHERE is_active" if active_only else ""
rows = await db.execute(text(f"""
SELECT id, name, percentage::float AS percentage, discount_type, description, manual_active,
bubble_text, bubble_color,
(is_active AND (start_at IS NULL OR start_at <= now())
AND (end_at IS NULL OR end_at >= now())) AS live
FROM sales_discount {where} ORDER BY manual_active DESC, percentage DESC
"""))
return {"discounts": [dict(r) for r in rows.mappings()]}
@router.post("/discounts/{discount_id}/toggle")
async def toggle_discount(discount_id: int, ident=Depends(require_token), db=Depends(get_db)):
row = (await db.execute(text(
"UPDATE sales_discount SET manual_active = NOT manual_active WHERE id=:i RETURNING manual_active"
), {"i": discount_id})).first()
if not row:
raise HTTPException(404, "not found")
await db.commit()
return {"ok": True, "manual_active": row[0]}
# ── Store Settings — the WordPress General/Reading replacement (one screen, kv-backed) ─────────
_SETTING_DEFAULTS = {
"store_name": "RecordGod", "store_tagline": "", "store_logo": "", "store_address": "",
"contact_email": "", "contact_phone": "", "abn": "", "timezone": "Australia/Sydney",
"store_url": "", "social_instagram": "", "social_facebook": "",
"currency_symbol": "$", "tax_rate": "0", "tax_type": "exclusive", "discount_rate": "0",
"policy_returns": "", "policy_shipping": "", "policy_privacy": "",
"receipt_footer": "thanks for digging", "homepage": "storefront",
"seo_title": "", "seo_description": "",
"meta_pixel_id": "", "umami_website_id": "", "umami_src": "", "ga4_id": "",
}
# some keys are stored under legacy names in the kv table
_SETTING_ALIASES = {"tax_rate": "default_tax_rate", "discount_rate": "default_discount_rate"}
async def _settings(db):
rows = await db.execute(text("SELECT setting_key, setting_value FROM sales_setting WHERE is_active"))
s = {r["setting_key"]: r["setting_value"] for r in rows.mappings()}
return {k: s.get(_SETTING_ALIASES.get(k, k), s.get(k, d)) for k, d in _SETTING_DEFAULTS.items()}
@router.get("/settings")
async def get_settings(ident=Depends(require_token), db=Depends(get_db)):
return await _settings(db)
@router.post("/settings")
async def save_settings(body: dict, ident=Depends(require_token), db=Depends(get_db)):
for k, v in body.items():
if k not in _SETTING_DEFAULTS or v is None:
continue
await db.execute(text("""
INSERT INTO sales_setting (setting_key, setting_value, is_active, updated_at)
VALUES (:k, :v, true, now())
ON CONFLICT (setting_key) DO UPDATE SET setting_value = :v, is_active = true, updated_at = now()
"""), {"k": _SETTING_ALIASES.get(k, k), "v": str(v)})
await db.commit()
return {"ok": True}
# ── Payment terminal (Square) — push the sale to a paired Square Terminal, poll, then finalize ──
@router.get("/terminal/status")
async def terminal_status(ident=Depends(require_token), db=Depends(get_db)):
return await square.status(db)
@router.post("/terminal/pair")
async def terminal_pair(ident=Depends(require_token), db=Depends(get_db)):
try:
return await square.create_device_code(db)
except square.SquareUnconfigured as e:
raise HTTPException(503, str(e))
except square.SquareError as e:
raise HTTPException(502, str(e))
@router.get("/terminal/pair/{code_id}")
async def terminal_pair_poll(code_id: str, ident=Depends(require_token), db=Depends(get_db)):
try:
return await square.get_device_code(db, code_id)
except square.SquareError as e:
raise HTTPException(502, str(e))
class TerminalCheckoutIn(BaseModel):
amount: float
reference: str | None = None
note: str | None = None
@router.post("/terminal/checkout")
async def terminal_checkout(body: TerminalCheckoutIn, ident=Depends(require_token), db=Depends(get_db)):
try:
return await square.create_checkout(db, body.amount, body.reference, body.note)
except square.SquareUnconfigured as e:
raise HTTPException(503, str(e))
except square.SquareError as e:
raise HTTPException(502, str(e))
@router.get("/terminal/checkout/{checkout_id}")
async def terminal_checkout_poll(checkout_id: str, ident=Depends(require_token), db=Depends(get_db)):
try:
return await square.get_checkout(db, checkout_id)
except square.SquareError as e:
raise HTTPException(502, str(e))
@router.post("/terminal/checkout/{checkout_id}/cancel")
async def terminal_checkout_cancel(checkout_id: str, ident=Depends(require_token), db=Depends(get_db)):
try:
return await square.cancel_checkout(db, checkout_id)
except square.SquareError as e:
raise HTTPException(502, str(e))
# ── AusPost shipping quote — flat rate by weight bracket × parcels (rates mirrored from WowPlatter) ──
@router.get("/shipping/quote")
async def shipping_quote(units: int | None = None, weight_g: int | None = None,
ident=Depends(require_token), db=Depends(get_db)):
"""Quote AusPost postage: N records → 230g + 50g packaging each → split into ≤5kg parcels → flat rate."""
if weight_g is None:
weight_g = max(1, units or 1) * 280
parcels = max(1, -(-weight_g // 5000)) # ceil
per = -(-weight_g // parcels) # ceil per-parcel grams
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, "per_parcel_g": per, "options": opts}
# ── Past sales (the migrated history — receipts / lookup / refund) ────────────────────────────
@router.get("/past")
async def past_sales(q: str = Query(""), ident=Depends(require_token), db=Depends(get_db)):
where, params = "", {}
if q.strip():
where = ("WHERE s.sale_number ILIKE :q "
"OR trim(c.first_name || ' ' || coalesce(c.last_name,'')) ILIKE :q")
params = {"q": f"%{q.strip()}%"}
rows = await db.execute(text(f"""
SELECT s.id, s.sale_number, s.total::float AS total, s.status, s.payment_method,
s.payment_status, s.sale_date,
coalesce(nullif(trim(c.first_name || ' ' || coalesce(c.last_name,'')), ''), 'Guest') AS customer,
(SELECT count(*) FROM sale_items si WHERE si.sale_id = s.id) AS items
FROM sales s LEFT JOIN customer c ON c.id = s.customer_id
{where} ORDER BY s.sale_date DESC NULLS LAST LIMIT 100
"""), params)
return {"sales": [dict(r) for r in rows.mappings()]}
# Parametric catch-all LAST so it doesn't swallow /settings, /customers, /past, /discounts
# (FastAPI matches by registration order; "/{sale_id}" regex matches any single segment).
@router.get("/{sale_id}")
async def sale_detail(sale_id: int, ident=Depends(require_token), db=Depends(get_db)):
s, items, cust = await _load_sale(db, sale_id)
return {"sale": s, "items": items, "customer": cust}