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}