"""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()