RECORDGOD/app/payments.py
type-two db1788bfe5 feat(app): payments/packs/discogs-mp/dealgod modules + admin, shop, sales route work
Batch-committing the in-flight app work (all modules compile clean):
- app/payments.py, app/packs.py, app/discogs_mp.py, app/dealgod.py (new)
- admin_routes, main, navigator/sales/shop routes, site/search.html
- customer_woo_sync + disc_sync tweaks; test_dealgod_supply, test_startup_ddl

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 23:07:28 +10:00

142 lines
7.1 KiB
Python

"""Provider-agnostic payments — StoreGod's replacement for the WooCommerce payment plugins (Square/PayPal/Stripe).
Every modern processor follows the SAME shape: the frontend SDK tokenises the card (raw card data never touches our
server → PCI SAQ A), the backend charges the token, optional refund/webhook. So they all implement ONE interface and
adding a processor = filling in one function. This is the anti-Ghost: bring whatever processor you already use.
The merchant's OWN keys live in the vault; money flows merchant → their processor → their bank. StoreGod never holds
funds. Amounts are integer CENTS (minor units), AUD by default.
Tested live here: `mock` (fake money, proves the flow). `square` is real + ready (the store's prod creds are set —
needs a Square application_id for the web SDK before a live web charge). `stripe`/`paypal` are coded to spec but
marked UNTESTED until their keys are added — the interface is the point, each is one function.
"""
import os
from uuid import uuid4
import httpx
SQUARE_VERSION = "2024-12-18"
def _vault():
from . import vault # lazy so this module imports cleanly for the standalone selfcheck
return vault
# ── adapters: charge(db, token, cents, currency, ref) -> {ok, payment_id?, status?, provider, error?} ──
async def _mock_charge(db, token, cents, currency, ref):
"""Test provider — no money, proves the order→charge→paid flow. token 'mock-decline' simulates a decline."""
if token == "mock-decline":
return {"ok": False, "provider": "mock", "error": "card declined (mock)"}
return {"ok": True, "provider": "mock", "payment_id": "mock_" + (ref or uuid4().hex[:8]), "status": "COMPLETED"}
async def _square_charge(db, token, cents, currency, ref):
tok = await _vault().get_secret(db, "square_access_token")
if not tok:
return {"ok": False, "provider": "square", "error": "square not configured"}
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"
loc = await _vault().get_secret(db, "square_location_id")
body = {"source_id": token, "idempotency_key": (ref or uuid4().hex)[:45],
"amount_money": {"amount": cents, "currency": currency}}
if loc:
body["location_id"] = loc
try:
async with httpx.AsyncClient(timeout=30) as c:
r = await c.post(f"{base}/v2/payments", json=body,
headers={"Authorization": f"Bearer {tok}", "Square-Version": SQUARE_VERSION})
d = r.json()
except Exception as e:
return {"ok": False, "provider": "square", "error": str(e)}
if r.status_code == 200:
p = d["payment"]
return {"ok": True, "provider": "square", "payment_id": p["id"], "status": p["status"]}
return {"ok": False, "provider": "square", "error": (d.get("errors") or [{}])[0].get("detail", r.text[:200])}
async def _stripe_charge(db, token, cents, currency, ref):
# UNTESTED — needs stripe_secret_key. PaymentIntent create+confirm with a payment_method token from Stripe.js.
sk = await _vault().get_secret(db, "stripe_secret_key")
if not sk:
return {"ok": False, "provider": "stripe", "error": "stripe not configured"}
data = {"amount": str(cents), "currency": currency.lower(), "payment_method": token,
"confirm": "true", "automatic_payment_methods[enabled]": "true",
"automatic_payment_methods[allow_redirects]": "never"}
try:
async with httpx.AsyncClient(timeout=30) as c:
r = await c.post("https://api.stripe.com/v1/payment_intents", data=data,
headers={"Authorization": f"Bearer {sk}"},
params={"idempotency_key": ref} if ref else None)
d = r.json()
except Exception as e:
return {"ok": False, "provider": "stripe", "error": str(e)}
if r.status_code == 200 and d.get("status") in ("succeeded", "requires_capture"):
return {"ok": True, "provider": "stripe", "payment_id": d["id"], "status": d["status"]}
return {"ok": False, "provider": "stripe", "error": (d.get("error") or {}).get("message", r.text[:200])}
async def _paypal_charge(db, token, cents, currency, ref):
# UNTESTED — needs paypal_client_id/secret. `token` = a PayPal order id already approved client-side; we capture it.
cid = await _vault().get_secret(db, "paypal_client_id")
sec = await _vault().get_secret(db, "paypal_secret")
if not (cid and sec):
return {"ok": False, "provider": "paypal", "error": "paypal not configured"}
sandbox = (await _vault().get_secret(db, "paypal_environment") or "live").lower().startswith("sand")
base = "https://api-m.sandbox.paypal.com" if sandbox else "https://api-m.paypal.com"
try:
async with httpx.AsyncClient(timeout=30) as c:
r = await c.post(f"{base}/v2/checkout/orders/{token}/capture", auth=(cid, sec),
headers={"Content-Type": "application/json"})
d = r.json()
except Exception as e:
return {"ok": False, "provider": "paypal", "error": str(e)}
if r.status_code in (200, 201) and d.get("status") == "COMPLETED":
return {"ok": True, "provider": "paypal", "payment_id": d["id"], "status": d["status"]}
return {"ok": False, "provider": "paypal", "error": d.get("message", r.text[:200])}
CHARGERS = {"mock": _mock_charge, "square": _square_charge, "stripe": _stripe_charge, "paypal": _paypal_charge}
async def charge(db, provider, token, cents, currency="AUD", ref=None):
"""Dispatch a charge to the chosen provider. cents = integer minor units."""
fn = CHARGERS.get((provider or "").lower())
if not fn:
return {"ok": False, "error": f"unknown provider '{provider}'"}
if not isinstance(cents, int) or cents <= 0:
return {"ok": False, "error": "amount must be a positive integer (cents)"}
return await fn(db, token, cents, currency, ref)
async def enabled_providers(db):
"""Which processors this store has keys for — the checkout offers exactly these. The `mock` provider is
a footgun on a public checkout (free 'paid' orders), so it's gated behind STOREGOD_ALLOW_MOCK_PAY=1 —
on for pre-launch testing, OFF before the storefront goes public."""
out = ["mock"] if os.getenv("STOREGOD_ALLOW_MOCK_PAY") == "1" else []
if await _vault().get_secret(db, "square_access_token"):
out.append("square")
if await _vault().get_secret(db, "stripe_secret_key"):
out.append("stripe")
if await _vault().get_secret(db, "paypal_client_id"):
out.append("paypal")
return out
def _selfcheck():
import asyncio
async def t():
assert (await charge(None, "mock", "tok", 1999))["ok"] is True
assert (await charge(None, "mock", "mock-decline", 1999))["ok"] is False
assert (await charge(None, "nope", "x", 100))["ok"] is False # unknown provider
assert (await charge(None, "mock", "x", 0))["ok"] is False # bad amount
print("ok")
asyncio.run(t())
if __name__ == "__main__":
_selfcheck()