From 80ff669caca894fe29709142fa8c8034b85e5b12 Mon Sep 17 00:00:00 2001 From: type-two Date: Mon, 22 Jun 2026 01:43:14 +1000 Subject: [PATCH] =?UTF-8?q?feat(pos):=20Sales=20tab=20=E2=80=94=20ring-up,?= =?UTF-8?q?=20discounts,=20payment=20plans,=20holds,=20locate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/main.py | 34 +++++++-- app/sales_routes.py | 155 +++++++++++++++++++++++++++++++++++++++++ site/nav.js | 1 + site/pos.html | 163 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 347 insertions(+), 6 deletions(-) create mode 100644 app/sales_routes.py create mode 100644 site/pos.html diff --git a/app/main.py b/app/main.py index 714e1e5..cb484c5 100644 --- a/app/main.py +++ b/app/main.py @@ -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 .layout_routes import router as layout_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 sqlalchemy import text as _sqltext # noqa: E402 @@ -31,16 +32,37 @@ app.include_router(settings_router) app.include_router(admin_router) app.include_router(layout_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") 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: - await conn.execute(_sqltext( - "CREATE TABLE IF NOT EXISTS store_config " - "(store_id int PRIMARY KEY, config jsonb NOT NULL, " - "updated_at timestamptz NOT NULL DEFAULT now())")) + for _stmt in _STARTUP_DDL: + await conn.execute(_sqltext(_stmt)) @app.get("/healthz") @@ -58,7 +80,7 @@ def _page(filename): 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) # Mounts come last so the API routes + /healthz match first; "/" serves the landing/dash site. diff --git a/app/sales_routes.py b/app/sales_routes.py new file mode 100644 index 0000000..72815c0 --- /dev/null +++ b/app/sales_routes.py @@ -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} diff --git a/site/nav.js b/site/nav.js index 7d7ad24..839eb55 100644 --- a/site/nav.js +++ b/site/nav.js @@ -3,6 +3,7 @@ (function () { const LINKS = [ { href: '/admin', label: 'Admin' }, + { href: '/pos', label: 'Sales' }, { href: '/builder', label: 'Builder' }, { href: '/shop', label: 'Shop' }, { href: '/store/', label: '3D store' }, diff --git a/site/pos.html b/site/pos.html new file mode 100644 index 0000000..c77e777 --- /dev/null +++ b/site/pos.html @@ -0,0 +1,163 @@ + + + + + +RecordGod — sales + + + + +

RecordGod · sales

+
+
+ +
+
+

🔍 Ring up — search title / artist / SKU / scan

+
+
+

🛒 Cart

+ +
ItemQtyPriceDiscLine
+
cart is empty — search above to add items
+
+ +
+

Total

+
Subtotal$0.00
+
Cart discount
+
Trade-in credit
+
Tax %
+
Total$0.00
+
+ + + +
+
+ +

💳 Payment plan / hold

+
Deposit + Due
+
Reserves the items, takes the deposit, balance due by the date.
+ +

📋 Open holds — collect balance

+
loading…
+
+
+ + + +