feat(pos): Square Terminal payments — push checkout to a paired terminal
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>
This commit is contained in:
parent
5673ee125d
commit
4ab9c09899
@ -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)):
|
||||
|
||||
@ -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",
|
||||
}
|
||||
|
||||
|
||||
|
||||
105
app/square.py
Normal file
105
app/square.py
Normal file
@ -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}
|
||||
@ -112,6 +112,7 @@
|
||||
<button data-m="cash" onclick="setMethod('cash')">💵 Cash</button>
|
||||
<button data-m="card" onclick="setMethod('card')">💳 Card</button>
|
||||
<button data-m="eftpos" onclick="setMethod('eftpos')">EFTPOS</button>
|
||||
<button data-m="terminal" id="btnTerminal" onclick="setMethod('terminal')" style="display:none">📟 Terminal</button>
|
||||
<button data-m="split" onclick="setMethod('split')"> Split</button>
|
||||
<button data-m="layby" onclick="setMethod('layby')">⏸ Layby</button>
|
||||
</div>
|
||||
@ -129,6 +130,9 @@
|
||||
<div class="tot"><span class="muted">Deposit</span><input id="dep" class="num" type="number" step="0.01" value="0"></div>
|
||||
<div class="tot"><span class="muted">Due date</span><input id="due" type="date"></div>
|
||||
</div>
|
||||
<div id="pTerminal" class="pp" style="display:none">
|
||||
<div class="muted">Press <b>Push to terminal</b> — the total goes to the Square Terminal for the customer to tap / insert. The sale finalises only when the terminal approves.</div>
|
||||
</div>
|
||||
|
||||
<input id="saleNote" placeholder="note (optional, prints on receipt)" style="width:100%;margin-top:10px">
|
||||
<button class="btn big" id="completeBtn" style="margin-top:10px" onclick="complete()">Complete sale</button>
|
||||
@ -179,6 +183,12 @@
|
||||
<div class="row"><input id="testEmail" type="email" placeholder="you@example.com" style="flex:1"><button class="btn" onclick="emailTest()">Send test</button></div>
|
||||
<div id="testMsg" class="muted" style="margin-top:6px"></div>
|
||||
</div>
|
||||
<div class="panel"><h2>📟 Payment terminal (Square)</h2>
|
||||
<div class="muted" style="margin-bottom:8px">Push card payments straight to a Square Terminal. Keys live in <a class="pink" href="/admin" target="_blank">admin → Connections</a>.</div>
|
||||
<div id="termStatus" class="muted">checking…</div>
|
||||
<div class="row" style="margin-top:8px"><button class="btn" onclick="pairTerminal()">Pair a terminal</button></div>
|
||||
<div id="pairOut" style="margin-top:8px"></div>
|
||||
</div>
|
||||
<div class="panel"><h2>Promotions & discounts <span class="muted">— switch on to apply at the counter</span></h2>
|
||||
<div id="promoList" class="muted">loading…</div></div>
|
||||
</div>
|
||||
@ -190,7 +200,7 @@
|
||||
<script>
|
||||
const $=s=>document.querySelector(s);
|
||||
let TOKEN=localStorage.getItem('rg_token')||'', cart=[], customer={id:0,name:'Guest'};
|
||||
let CUR='$', promos=[], method='cash', splits=[], lastSale=null;
|
||||
let CUR='$', promos=[], method='cash', splits=[], lastSale=null, TS={}, TERM=null;
|
||||
const hdr=()=>({'Authorization':'Bearer '+TOKEN,'Content-Type':'application/json'});
|
||||
const money=n=>CUR+Number(n||0).toFixed(2);
|
||||
const esc=s=>(s||'').replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));
|
||||
@ -203,7 +213,7 @@ async function signin(){
|
||||
$('#who').textContent=(await me.json()).name||'';
|
||||
localStorage.setItem('rg_token',TOKEN);
|
||||
$('#gate').style.display='none'; $('#app').style.display='block';
|
||||
loadSettings(); loadPromos(); setMethod('cash');
|
||||
loadSettings(); loadPromos(); loadTerminal(); setMethod('cash');
|
||||
}
|
||||
function tab(name){
|
||||
document.querySelectorAll('.tab').forEach(t=>t.classList.remove('on'));
|
||||
@ -298,11 +308,12 @@ function rm(i){ cart.splice(i,1); render(); }
|
||||
function setMethod(m){
|
||||
method=m;
|
||||
document.querySelectorAll('.pm button').forEach(b=>b.classList.toggle('on',b.dataset.m===m));
|
||||
['Cash','Split','Layby'].forEach(p=>$('#p'+p).style.display='none');
|
||||
['Cash','Split','Layby','Terminal'].forEach(p=>$('#p'+p).style.display='none');
|
||||
if(m==='cash'){ $('#pCash').style.display='block'; $('#tendered').focus&&setTimeout(()=>$('#tendered').focus(),30); }
|
||||
if(m==='split'){ $('#pSplit').style.display='block'; if(!splits.length) splits=[{method:'cash',amount:0}]; renderSplit(totals().total); }
|
||||
if(m==='layby'){ $('#pLayby').style.display='block'; }
|
||||
$('#completeBtn').textContent = m==='layby'?'Hold layby & take deposit':'Complete '+m+' sale';
|
||||
if(m==='terminal'){ $('#pTerminal').style.display='block'; }
|
||||
$('#completeBtn').textContent = m==='layby'?'Hold layby & take deposit':(m==='terminal'?'Push to terminal':'Complete '+m+' sale');
|
||||
}
|
||||
function addSplit(){ splits.push({method:'card',amount:0}); renderSplit(totals().total); }
|
||||
function renderSplit(total){
|
||||
@ -324,6 +335,7 @@ function payload(){
|
||||
}
|
||||
async function complete(){
|
||||
if(!cart.length){ $('#msg').textContent='cart is empty'; return; }
|
||||
if(method==='terminal'){ runTerminal(); return; }
|
||||
const t=totals(); let body={...payload()};
|
||||
if(method==='layby'){
|
||||
body.payment_method='layby'; body.hold={deposit:+$('#dep').value||0, due_date:$('#due').value||null};
|
||||
@ -343,6 +355,59 @@ async function complete(){
|
||||
else $('#msg').innerHTML='<span class="warn">failed</span>';
|
||||
}
|
||||
|
||||
/* ── Square Terminal ── */
|
||||
async function loadTerminal(){
|
||||
TS=await get('/sales/terminal/status').catch(()=>({}));
|
||||
$('#btnTerminal').style.display = TS.paired ? '' : 'none';
|
||||
renderTermSettings();
|
||||
}
|
||||
function renderTermSettings(){
|
||||
const e=$('#termStatus'); if(!e) return;
|
||||
if(!TS.configured){ e.innerHTML='<span class="warn">Square not set up — add the keys in admin → Connections.</span>'; return; }
|
||||
e.innerHTML = TS.paired ? `<span class="ok">✓ Terminal paired</span> <span class="muted">device ${esc(TS.device_id||'')}</span>`
|
||||
: 'Square connected — no terminal paired yet. Click “Pair a terminal”.';
|
||||
}
|
||||
async function pairTerminal(){
|
||||
$('#pairOut').innerHTML='requesting a pairing code…';
|
||||
const d=await post('/sales/terminal/pair',{});
|
||||
if(!d.code){ $('#pairOut').innerHTML='<span class="warn">'+esc(d.detail||'failed')+'</span>'; return; }
|
||||
$('#pairOut').innerHTML=`<div style="text-align:center;background:#fdeef6;border-radius:10px;padding:14px">
|
||||
On the Square Terminal: <b>Sign in → Use a device code</b>, then enter
|
||||
<div style="font-size:34px;letter-spacing:5px;font-weight:700;margin:8px 0">${esc(d.code)}</div>
|
||||
<div class="muted" id="pairPoll">waiting for the terminal…</div></div>`;
|
||||
const id=d.id, t=setInterval(async()=>{
|
||||
const p=await get('/sales/terminal/pair/'+id).catch(()=>({}));
|
||||
if(p.device_id){ clearInterval(t); $('#pairOut').innerHTML='<span class="ok">✓ Paired — device '+esc(p.device_id)+'</span>'; loadTerminal(); }
|
||||
}, 3000);
|
||||
setTimeout(()=>clearInterval(t), 300000);
|
||||
}
|
||||
async function runTerminal(){
|
||||
const t=totals(); if(t.total<=0){ $('#msg').textContent='nothing to charge'; return; }
|
||||
$('#msg').textContent='pushing to terminal…';
|
||||
const d=await post('/sales/terminal/checkout',{amount:t.total,reference:'POS',note:cart.map(c=>c.title).join(', ').slice(0,400)});
|
||||
if(!d.id){ $('#msg').innerHTML='<span class="warn">'+esc(d.detail||'terminal error')+'</span>'; return; }
|
||||
$('#msg').textContent='';
|
||||
const cid=d.id;
|
||||
$('#dlgCard').innerHTML=`<h3 style="margin:0 0 8px">📟 Square Terminal</h3>
|
||||
<div style="text-align:center;padding:10px 0"><div class="pink" style="font-size:30px;font-weight:700">${money(t.total)}</div>
|
||||
<div class="muted" id="termMsg" style="margin-top:8px">waiting for customer to tap / insert…</div></div>
|
||||
<button class="ghost" style="width:100%" onclick="cancelTerminal('${cid}')">Cancel</button>`;
|
||||
$('#dlg').style.display='flex';
|
||||
TERM=setInterval(async()=>{
|
||||
const p=await get('/sales/terminal/checkout/'+cid).catch(()=>({}));
|
||||
if(p.status==='COMPLETED'){ clearInterval(TERM); close_('dlg'); finalizeSale('square'); }
|
||||
else if(p.status==='CANCELED'||p.status==='CANCEL_REQUESTED'){ clearInterval(TERM); close_('dlg'); $('#msg').innerHTML='<span class="warn">cancelled at terminal</span>'; }
|
||||
else if(p.status){ const m=$('#termMsg'); if(m) m.textContent='status: '+String(p.status).toLowerCase().replace(/_/g,' ')+'…'; }
|
||||
}, 2000);
|
||||
setTimeout(()=>{ if(TERM){ clearInterval(TERM); } }, 180000);
|
||||
}
|
||||
function cancelTerminal(cid){ if(TERM) clearInterval(TERM); fetch('/sales/terminal/checkout/'+cid+'/cancel',{method:'POST',headers:hdr()}); close_('dlg'); $('#msg').textContent='cancelled'; }
|
||||
async function finalizeSale(payMethod){
|
||||
const d=await post('/sales',{...payload(),payment_method:payMethod,tendered:totals().total});
|
||||
if(d.ok){ lastSale=d.sale_id; showReceipt(d.sale_id); cart=[]; splits=[]; $('#saleNote').value=''; render(); }
|
||||
else $('#msg').innerHTML='<span class="warn">paid on terminal but sale-save failed — check History</span>';
|
||||
}
|
||||
|
||||
/* ── receipt ── */
|
||||
let rcptHtml='';
|
||||
async function showReceipt(id){
|
||||
|
||||
Loading…
Reference in New Issue
Block a user