feat(pos): Sales tab — ring-up, discounts, payment plans, holds, locate

This commit is contained in:
type-two 2026-06-22 01:43:14 +10:00
parent e8e526f48e
commit 80ff669cac
4 changed files with 347 additions and 6 deletions

View File

@ -21,6 +21,7 @@ from .settings_routes import router as settings_router # noqa: E402
from .admin_routes import router as admin_router # noqa: E402 from .admin_routes import router as admin_router # noqa: E402
from .layout_routes import router as layout_router # noqa: E402 from .layout_routes import router as layout_router # noqa: E402
from .shop_routes import router as shop_router # noqa: E402 from .shop_routes import router as shop_router # noqa: E402
from .sales_routes import router as sales_router # noqa: E402
from .db import engine # noqa: E402 from .db import engine # noqa: E402
from sqlalchemy import text as _sqltext # noqa: E402 from sqlalchemy import text as _sqltext # noqa: E402
@ -31,16 +32,37 @@ app.include_router(settings_router)
app.include_router(admin_router) app.include_router(admin_router)
app.include_router(layout_router) app.include_router(layout_router)
app.include_router(shop_router) app.include_router(shop_router)
app.include_router(sales_router)
_STARTUP_DDL = [
"CREATE TABLE IF NOT EXISTS store_config (store_id int PRIMARY KEY, config jsonb NOT NULL, updated_at timestamptz NOT NULL DEFAULT now())",
# POS fields on the migrated sales tables
"ALTER TABLE sales ADD COLUMN IF NOT EXISTS subtotal numeric(10,2)",
"ALTER TABLE sales ADD COLUMN IF NOT EXISTS discount_amount numeric(10,2) DEFAULT 0",
"ALTER TABLE sales ADD COLUMN IF NOT EXISTS payment_status text DEFAULT 'paid'",
"ALTER TABLE sales ADD COLUMN IF NOT EXISTS amount_paid numeric(10,2) DEFAULT 0",
"ALTER TABLE sales ADD COLUMN IF NOT EXISTS hold_expires_at timestamptz",
"ALTER TABLE sales ADD COLUMN IF NOT EXISTS split_payments jsonb",
"ALTER TABLE sales ADD COLUMN IF NOT EXISTS trade_in_credit numeric(10,2) DEFAULT 0",
"ALTER TABLE sale_items ADD COLUMN IF NOT EXISTS discount_amount numeric(10,2) DEFAULT 0",
"ALTER TABLE sale_items ADD COLUMN IF NOT EXISTS original_price numeric(10,2)",
# auto-id for new POS rows (migrated tables came without sequences)
"CREATE SEQUENCE IF NOT EXISTS sales_id_seq",
"ALTER TABLE sales ALTER COLUMN id SET DEFAULT nextval('sales_id_seq')",
"SELECT setval('sales_id_seq', GREATEST(1, (SELECT coalesce(max(id),0) FROM sales)))",
"CREATE SEQUENCE IF NOT EXISTS sale_items_id_seq",
"ALTER TABLE sale_items ALTER COLUMN id SET DEFAULT nextval('sale_items_id_seq')",
"SELECT setval('sale_items_id_seq', GREATEST(1, (SELECT coalesce(max(id),0) FROM sale_items)))",
]
@app.on_event("startup") @app.on_event("startup")
async def _ensure_tables(): async def _ensure_tables():
# new tables that aren't part of the original schema.sql load — create lazily on deploy # lazy migrations on deploy (store_config + POS columns/sequences)
async with engine.begin() as conn: async with engine.begin() as conn:
await conn.execute(_sqltext( for _stmt in _STARTUP_DDL:
"CREATE TABLE IF NOT EXISTS store_config " await conn.execute(_sqltext(_stmt))
"(store_id int PRIMARY KEY, config jsonb NOT NULL, "
"updated_at timestamptz NOT NULL DEFAULT now())"))
@app.get("/healthz") @app.get("/healthz")
@ -58,7 +80,7 @@ def _page(filename):
return _serve return _serve
for _stub in ("shop", "builder", "admin", "dash"): for _stub in ("shop", "builder", "admin", "dash", "pos"):
app.add_api_route(f"/{_stub}", _page(_stub + ".html"), methods=["GET"], include_in_schema=False) app.add_api_route(f"/{_stub}", _page(_stub + ".html"), methods=["GET"], include_in_schema=False)
# Mounts come last so the API routes + /healthz match first; "/" serves the landing/dash site. # Mounts come last so the API routes + /healthz match first; "/" serves the landing/dash site.

155
app/sales_routes.py Normal file
View File

@ -0,0 +1,155 @@
import json
import secrets
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy import text
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
customer: str | None = None
class PayIn(BaseModel):
amount: float
method: str = "cash"
@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()
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, trade_in_credit,
hold_expires_at, split_payments, sale_date, created_at)
VALUES (:sn, NULL, :sub, :disc, :tax, :total, :st, :pm, :ps, :paid, :trade,
:due, CAST(:split AS jsonb), now(), now())
RETURNING id
"""), {"sn": sale_number, "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, "trade": body.trade_in, "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()]}
@router.get("/{sale_id}")
async def sale_detail(sale_id: int, ident=Depends(require_token), db=Depends(get_db)):
s = (await db.execute(text("SELECT * FROM sales WHERE id=:i"), {"i": sale_id})).mappings().first()
if not s:
raise HTTPException(404, "not found")
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()]
return {"sale": dict(s), "items": items}
@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}

View File

@ -3,6 +3,7 @@
(function () { (function () {
const LINKS = [ const LINKS = [
{ href: '/admin', label: 'Admin' }, { href: '/admin', label: 'Admin' },
{ href: '/pos', label: 'Sales' },
{ href: '/builder', label: 'Builder' }, { href: '/builder', label: 'Builder' },
{ href: '/shop', label: 'Shop' }, { href: '/shop', label: 'Shop' },
{ href: '/store/', label: '3D store' }, { href: '/store/', label: '3D store' },

163
site/pos.html Normal file
View File

@ -0,0 +1,163 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>RecordGod — sales</title>
<script defer src="/nav.js"></script>
<style>
:root{--pink:#ff5db1;--ink:#0b0b0d;--pn:#141418;--line:#26262c;--mut:#9a9aa6;--ok:#46d18a;--warn:#e6a046}
*{box-sizing:border-box}
html,body{margin:0;background:var(--ink);color:#f0f0f2;font:14px/1.5 system-ui,sans-serif}
button{cursor:pointer;border:0;border-radius:8px;font:500 13px system-ui}
.btn{background:var(--pink);color:#1a0a14;padding:10px 14px}
.ghost{background:#1d1d22;color:#ccc;border:1px solid var(--line);padding:8px 11px}
input,select{padding:8px 9px;border:1px solid var(--line);border-radius:7px;background:#0e0e11;color:#eee;font:14px system-ui}
#gate{position:fixed;inset:0;display:flex;align-items:center;justify-content:center;background:var(--ink);z-index:50}
#gate .b{width:320px;text-align:center}#gate h1{color:var(--pink)}
#app{display:none;grid-template-columns:1.3fr 1fr;gap:18px;padding:18px;max-width:1200px;margin:0 auto}
.panel{background:var(--pn);border:1px solid var(--line);border-radius:12px;padding:14px;margin-bottom:14px}
.panel h2{font-size:14px;font-weight:600;margin:0 0 10px}
.row{display:flex;gap:8px;align-items:center}
.res{max-height:240px;overflow:auto;margin-top:8px}
.ri{display:flex;gap:10px;align-items:center;padding:7px;border-radius:8px;cursor:pointer}
.ri:hover{background:#17171c}.ri img,.ri .ph{width:38px;height:38px;border-radius:5px;object-fit:cover;background:#222;flex-shrink:0}
.ri .t{flex:1;min-width:0}.ri .t .nm{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.muted{color:var(--mut);font-size:12px}.pink{color:var(--pink)}
table{width:100%;border-collapse:collapse;font-size:13px}
td,th{padding:6px 5px;border-bottom:1px solid #1b1b20;text-align:left}
th{color:var(--mut);font-weight:500}
.qty{width:46px}.disc{width:62px}
.tot{display:flex;justify-content:space-between;padding:4px 0}
.tot.big{font-size:22px;font-weight:600;border-top:1px solid var(--line);margin-top:6px;padding-top:10px}
.tot.big b{color:var(--pink)}
.pillbtns{display:flex;gap:8px;flex-wrap:wrap;margin:8px 0}
.pillbtns button{flex:1}
.hold{display:flex;gap:8px;align-items:center;padding:8px 0;border-bottom:1px solid #1b1b20;font-size:13px}
.x{color:var(--mut);cursor:pointer}
</style>
</head>
<body>
<div id="gate"><div class="b"><h1>Record<b style="color:var(--pink)">God</b> · sales</h1>
<div class="row"><input id="tok" type="password" placeholder="admin token" style="flex:1"><button class="btn" onclick="signin()">Enter</button></div>
<div id="gerr" class="muted" style="margin-top:8px"></div></div></div>
<div id="app">
<div>
<div class="panel"><h2>🔍 Ring up <span class="muted">— search title / artist / SKU / scan</span></h2>
<div class="row"><input id="q" placeholder="search or scan to add…" style="flex:1" autocomplete="off"></div>
<div id="res" class="res"></div></div>
<div class="panel"><h2>🛒 Cart</h2>
<table><thead><tr><th>Item</th><th>Qty</th><th>Price</th><th>Disc</th><th>Line</th><th></th></tr></thead>
<tbody id="cart"></tbody></table>
<div id="empty" class="muted" style="padding:10px 0">cart is empty — search above to add items</div></div>
</div>
<div>
<div class="panel"><h2>Total</h2>
<div class="tot"><span class="muted">Subtotal</span><span id="t-sub">$0.00</span></div>
<div class="tot"><span class="muted">Cart discount</span><input id="cartDisc" class="disc" type="number" step="0.01" value="0" oninput="render()"></div>
<div class="tot"><span class="muted">Trade-in credit</span><input id="trade" class="disc" type="number" step="0.01" value="0" oninput="render()"></div>
<div class="tot"><span class="muted">Tax %</span><input id="tax" class="disc" type="number" step="0.1" value="0" oninput="render()"></div>
<div class="tot big"><span>Total</span><b id="t-total">$0.00</b></div>
<div class="pillbtns">
<button class="btn" onclick="complete('cash')">Cash</button>
<button class="btn" onclick="complete('card')">Card</button>
<button class="btn" onclick="complete('eftpos')">EFTPOS</button>
</div>
<div id="msg" class="muted"></div></div>
<div class="panel"><h2>💳 Payment plan / hold</h2>
<div class="row"><span class="muted">Deposit</span><input id="dep" class="disc" type="number" step="0.01" value="0">
<span class="muted">Due</span><input id="due" type="date"><button class="ghost" onclick="hold()">Put on hold</button></div>
<div class="muted" style="margin-top:6px">Reserves the items, takes the deposit, balance due by the date.</div></div>
<div class="panel"><h2>📋 Open holds <span class="muted">— collect balance</span></h2>
<div id="holds" class="muted">loading…</div></div>
</div>
</div>
<script>
const $=s=>document.querySelector(s);
let TOKEN=localStorage.getItem('rg_token')||'', cart=[];
const hdr=()=>({'Authorization':'Bearer '+TOKEN,'Content-Type':'application/json'});
const money=n=>'$'+Number(n||0).toFixed(2);
const esc=s=>(s||'').replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
async function signin(){
TOKEN=$('#tok').value.trim();
const r=await fetch('/sales?status=holds',{headers:hdr()});
if(!r.ok){$('#gerr').textContent='invalid token';return;}
localStorage.setItem('rg_token',TOKEN);
$('#gate').style.display='none'; $('#app').style.display='grid';
$('#q').focus(); loadHolds();
}
let st;
$('#q')&&($('#q').addEventListener('input',e=>{ clearTimeout(st); st=setTimeout(()=>doSearch(e.target.value),180); }));
async function doSearch(q){
if(!q.trim()){ $('#res').innerHTML=''; return; }
const d=await fetch('/sales/search?q='+encodeURIComponent(q),{headers:hdr()}).then(r=>r.json());
$('#res').innerHTML=d.items.map((it,i)=>`<div class="ri" onclick='add(${JSON.stringify(it).replace(/'/g,"&#39;")})'>
${it.thumb?`<img src="${it.thumb}">`:'<span class=ph></span>'}
<div class="t"><div class="nm">${esc(it.title||it.sku)}</div><div class="muted">${esc(it.artist||'')} · ${it.condition||''} ${it.crate?'· 📍 '+esc(it.crate):''}</div></div>
<div class="pink">${money(it.price)}</div></div>`).join('')||'<div class="muted" style="padding:8px">no in-stock match</div>';
}
function add(it){
const ex=cart.find(c=>c.sku===it.sku);
if(ex) ex.qty++; else cart.push({sku:it.sku,title:it.title||it.sku,qty:1,price:it.price||0,discount:0,crate:it.crate});
$('#q').value=''; $('#res').innerHTML=''; $('#q').focus(); render();
}
function render(){
$('#empty').style.display=cart.length?'none':'block';
$('#cart').innerHTML=cart.map((c,i)=>`<tr>
<td>${esc(c.title)}${c.crate?`<div class="muted">📍 ${esc(c.crate)}</div>`:''}</td>
<td><input class="qty" type="number" min="1" value="${c.qty}" oninput="upd(${i},'qty',this.value)"></td>
<td><input class="disc" type="number" step="0.01" value="${c.price}" oninput="upd(${i},'price',this.value)"></td>
<td><input class="disc" type="number" step="0.01" value="${c.discount}" oninput="upd(${i},'discount',this.value)"></td>
<td>${money(c.price*c.qty-c.discount)}</td><td><span class="x" onclick="rm(${i})"></span></td></tr>`).join('');
const gross=cart.reduce((s,c)=>s+c.price*c.qty,0), ld=cart.reduce((s,c)=>s+(+c.discount||0),0);
const cd=+$('#cartDisc').value||0, tr=+$('#trade').value||0, tx=+$('#tax').value||0;
const sub=gross-ld, after=sub-cd, total=after+after*tx/100-tr;
$('#t-sub').textContent=money(sub);
$('#t-total').textContent=money(Math.max(0,total));
}
function upd(i,k,v){ cart[i][k]= k==='qty'?Math.max(1,+v|0):(+v||0); render(); }
function rm(i){ cart.splice(i,1); render(); }
function payload(){
return { items:cart.map(c=>({sku:c.sku,title:c.title,qty:c.qty,unit_price:c.price,discount:+c.discount||0})),
cart_discount:+$('#cartDisc').value||0, trade_in:+$('#trade').value||0, tax_rate:+$('#tax').value||0 };
}
async function complete(method){
if(!cart.length){ $('#msg').textContent='cart is empty'; return; }
$('#msg').textContent='processing…';
const r=await fetch('/sales',{method:'POST',headers:hdr(),body:JSON.stringify({...payload(),payment_method:method})});
const d=await r.json();
if(d.ok){ $('#msg').innerHTML=`<span style="color:var(--ok)">✓ sale ${d.sale_number} · ${money(d.total)} ${method}</span>`; cart=[]; render(); loadHolds(); }
else $('#msg').textContent='failed';
}
async function hold(){
if(!cart.length){ $('#msg').textContent='cart is empty'; return; }
const dep=+$('#dep').value||0, due=$('#due').value||null;
const r=await fetch('/sales',{method:'POST',headers:hdr(),body:JSON.stringify({...payload(),payment_method:'hold',hold:{deposit:dep,due_date:due}})});
const d=await r.json();
if(d.ok){ $('#msg').innerHTML=`<span style="color:var(--ok)">✓ held ${d.sale_number} · deposit ${money(d.amount_paid)} · balance ${money(d.balance)}</span>`; cart=[]; $('#dep').value=0; render(); loadHolds(); }
}
async function loadHolds(){
const d=await fetch('/sales?status=holds',{headers:hdr()}).then(r=>r.json());
$('#holds').innerHTML=d.sales.length? d.sales.map(s=>`<div class="hold">
<span style="flex:1">${s.sale_number} · bal <b class="pink">${money(s.balance)}</b>${s.hold_expires_at?` · due ${String(s.hold_expires_at).slice(0,10)}`:''}</span>
<button class="ghost" onclick="payHold(${s.id},${s.balance})">Collect</button></div>`).join('')
: '<div class="muted">no open holds</div>';
}
async function payHold(id,bal){
const amt=parseFloat(prompt('Amount to collect (balance '+money(bal)+'):', bal.toFixed(2)));
if(!amt) return;
const d=await fetch('/sales/'+id+'/pay',{method:'POST',headers:hdr(),body:JSON.stringify({amount:amt,method:'cash'})}).then(r=>r.json());
if(d.ok){ loadHolds(); $('#msg').innerHTML=d.completed?`<span style="color:var(--ok)">✓ paid off</span>`:`<span class="muted">balance ${money(d.balance)}</span>`; }
}
if(TOKEN){ $('#tok').value=TOKEN; signin(); }
</script>
</body>
</html>