From 4ab9c09899d93b34ccd0b42b33ce968a2c581aaa Mon Sep 17 00:00:00 2001 From: type-two Date: Tue, 23 Jun 2026 17:57:26 +1000 Subject: [PATCH] =?UTF-8?q?feat(pos):=20Square=20Terminal=20payments=20?= =?UTF-8?q?=E2=80=94=20push=20checkout=20to=20a=20paired=20terminal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit app/square.py (Terminal Checkout API: device-code pairing, create/poll/cancel checkout; creds in vault, device_id in sales_setting). /sales/terminal/{status,pair,pair/{id}, checkout,checkout/{id},checkout/{id}/cancel}. POS: πŸ“Ÿ Terminal payment method (shown when paired) β†’ pushes total to the terminal, polls, finalises the sale only on COMPLETED; Settings β†’ Pair a terminal (device-code flow). square_* added to admin Connections. Co-Authored-By: Claude Opus 4.8 --- app/sales_routes.py | 58 ++++++++++++++++++++++- app/settings_routes.py | 3 ++ app/square.py | 105 +++++++++++++++++++++++++++++++++++++++++ site/pos.html | 73 ++++++++++++++++++++++++++-- 4 files changed, 234 insertions(+), 5 deletions(-) create mode 100644 app/square.py diff --git a/app/sales_routes.py b/app/sales_routes.py index 64004e2..d7777e0 100644 --- a/app/sales_routes.py +++ b/app/sales_routes.py @@ -6,7 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel from sqlalchemy import text -from . import mailer, receipts +from . import mailer, receipts, square from .auth import require_token from .db import get_db @@ -312,6 +312,62 @@ async def save_settings(body: dict, ident=Depends(require_token), db=Depends(get 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)) + + # ── 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)): diff --git a/app/settings_routes.py b/app/settings_routes.py index 4ef9498..97ac28c 100644 --- a/app/settings_routes.py +++ b/app/settings_routes.py @@ -28,6 +28,9 @@ KNOWN = { "smtp_pass": "Receipt email β€” SMTP password", "smtp_from": "Receipt email β€” From address (defaults to username)", "smtp_from_name": "Receipt email β€” From name (e.g. Monster Robot Records)", + "square_access_token": "Square access token (EAAA…) β€” drives the payment terminal", + "square_location_id": "Square location ID (e.g. S5D3EN3YM2AN8)", + "square_environment": "Square environment β€” production or sandbox", } diff --git a/app/square.py b/app/square.py new file mode 100644 index 0000000..0249755 --- /dev/null +++ b/app/square.py @@ -0,0 +1,105 @@ +import uuid + +import httpx +from sqlalchemy import text + +from . import vault + +# Square Terminal β€” push a POS payment to a paired Square Terminal device (Terminal Checkout API), +# poll for the result, finalize the sale only on COMPLETED. Creds: access_token + location_id in the +# vault (admin); the paired device_id in sales_setting (set by the pairing flow). +# ponytail: one provider (Square) behind /sales/terminal/* β€” other terminals (Tyro/Stripe) slot in here. +VERSION = "2024-12-18" +CURRENCY = "AUD" # ponytail: AUD-only for now; read from the Square location if multi-currency stores appear + + +class SquareUnconfigured(Exception): + pass + + +class SquareError(Exception): + pass + + +async def _cfg(db): + tok = await vault.get_secret(db, "square_access_token") + if not tok: + raise SquareUnconfigured("Square not set up β€” add square_access_token in admin β†’ Connections") + env = (await vault.get_secret(db, "square_environment") or "production").lower() + base = "https://connect.squareupsandbox.com" if env.startswith("sand") else "https://connect.squareup.com" + dev = (await db.execute(text( + "SELECT setting_value FROM sales_setting WHERE setting_key='square_device_id'"))).scalar() + return {"tok": tok, "loc": await vault.get_secret(db, "square_location_id") or "", + "base": base, "device_id": dev} + + +async def _req(method, url, tok, body=None): + async with httpx.AsyncClient(timeout=20) as c: + r = await c.request(method, url, json=body, headers={ + "Authorization": "Bearer " + tok, "Square-Version": VERSION, "Content-Type": "application/json"}) + if r.status_code >= 300: + try: + msg = (r.json().get("errors") or [{}])[0].get("detail") or r.text[:200] + except Exception: + msg = r.text[:200] + raise SquareError(f"{r.status_code}: {msg}") + return r.json() if r.content else {} + + +async def status(db): + try: + c = await _cfg(db) + except SquareUnconfigured: + return {"configured": False, "paired": False} + return {"configured": True, "paired": bool(c["device_id"]), "device_id": c["device_id"], "location": c["loc"]} + + +async def create_device_code(db, name="RecordGod POS"): + """Start pairing β€” returns a 6-char code the operator types into the Square Terminal.""" + c = await _cfg(db) + d = await _req("POST", c["base"] + "/v2/devices/codes", c["tok"], { + "idempotency_key": str(uuid.uuid4()), + "device_code": {"product_type": "TERMINAL_API", "location_id": c["loc"], "name": name}}) + dc = d["device_code"] + return {"id": dc["id"], "code": dc.get("code"), "status": dc.get("status")} + + +async def get_device_code(db, code_id): + """Poll a pairing code; once the terminal accepts it, persist the device_id.""" + c = await _cfg(db) + dc = (await _req("GET", c["base"] + f"/v2/devices/codes/{code_id}", c["tok"]))["device_code"] + device_id = dc.get("device_id") + if device_id: + await db.execute(text(""" + INSERT INTO sales_setting (setting_key, setting_value, updated_at) + VALUES ('square_device_id', :v, now()) + ON CONFLICT (setting_key) DO UPDATE SET setting_value = :v, updated_at = now()"""), {"v": device_id}) + await db.commit() + return {"status": dc.get("status"), "device_id": device_id} + + +async def create_checkout(db, amount, reference=None, note=None): + c = await _cfg(db) + if not c["device_id"]: + raise SquareUnconfigured("No terminal paired β€” pair one in POS β†’ Settings") + checkout = {"amount_money": {"amount": int(round(float(amount) * 100)), "currency": CURRENCY}, + "device_options": {"device_id": c["device_id"]}} + if reference: + checkout["reference_id"] = str(reference)[:40] + if note: + checkout["note"] = note[:500] + ck = (await _req("POST", c["base"] + "/v2/terminals/checkouts", c["tok"], + {"idempotency_key": str(uuid.uuid4()), "checkout": checkout}))["checkout"] + return {"id": ck["id"], "status": ck["status"]} + + +async def get_checkout(db, checkout_id): + c = await _cfg(db) + ck = (await _req("GET", c["base"] + f"/v2/terminals/checkouts/{checkout_id}", c["tok"]))["checkout"] + return {"id": ck["id"], "status": ck["status"], "payment_ids": ck.get("payment_ids", [])} + + +async def cancel_checkout(db, checkout_id): + c = await _cfg(db) + await _req("POST", c["base"] + f"/v2/terminals/checkouts/{checkout_id}/cancel", c["tok"]) + return {"ok": True} diff --git a/site/pos.html b/site/pos.html index 701447d..1f97354 100644 --- a/site/pos.html +++ b/site/pos.html @@ -112,6 +112,7 @@ + @@ -129,6 +130,9 @@
Deposit
Due date
+ @@ -179,6 +183,12 @@
+

πŸ“Ÿ Payment terminal (Square)

+
Push card payments straight to a Square Terminal. Keys live in admin β†’ Connections.
+
checking…
+
+
+

Promotions & discounts β€” switch on to apply at the counter

loading…
@@ -190,7 +200,7 @@