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>
424 lines
20 KiB
Python
424 lines
20 KiB
Python
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)
|
||
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()
|
||
|
||
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})
|
||
|
||
# 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:
|
||
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."""
|
||
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")
|
||
new_paid = round(s["paid"] + body.amount, 2)
|
||
done = new_paid >= s["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",
|
||
"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(s["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}
|