RECORDGOD/app/sales_routes.py
2026-06-24 01:05:12 +10:00

414 lines
19 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)
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()
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]}
# ── Settings (currency / tax / default discount) ─────────────────────────────────────────────
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 {"currency_symbol": s.get("currency_symbol", "$"),
"tax_rate": s.get("default_tax_rate", s.get("tax_rate", "0")),
"discount_rate": s.get("default_discount_rate", "0"),
"tax_type": s.get("tax_type", "exclusive"),
"store_name": s.get("store_name", "RecordGod"),
"store_address": s.get("store_address", ""),
"receipt_footer": s.get("receipt_footer", "thanks for digging"),
"store_logo": s.get("store_logo", "")}
@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)):
m = {"currency_symbol": body.get("currency_symbol"), "default_tax_rate": body.get("tax_rate"),
"default_discount_rate": body.get("discount_rate"), "tax_type": body.get("tax_type"),
"store_name": body.get("store_name"), "store_address": body.get("store_address"),
"receipt_footer": body.get("receipt_footer"), "store_logo": body.get("store_logo")}
for k, v in m.items():
if v is None:
continue
await db.execute(text("""
INSERT INTO sales_setting (setting_key, setting_value, updated_at) VALUES (:k, :v, now())
ON CONFLICT (setting_key) DO UPDATE SET setting_value = :v, updated_at = now()
"""), {"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}