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 <noreply@anthropic.com>
106 lines
4.4 KiB
Python
106 lines
4.4 KiB
Python
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}
|