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_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, trade_in_credit, hold_expires_at, split_payments, sale_date, created_at) VALUES (:sn, :cust, :sub, :disc, :tax, :total, :st, :pm, :ps, :paid, :trade, :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, "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} # ── 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) ───────────────────────────────────────────── @router.get("/settings") async def get_settings(ident=Depends(require_token), db=Depends(get_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")} @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")} 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} # ── 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()]}