"""Client de l'API SumUp — paiement de la cotisation (voir CLAUDE.md § 2, `FormTemplate`, et `app/api/forms.py`). `create_checkout`/`get_checkout` sont les points d'injection uniques pour les tests (remplacés comme `app.forms.captcha.verify_hcaptcha`). Best effort, comme l'envoi d'emails : toute erreur réseau/HTTP est journalisée (code/ classe d'exception seulement, jamais de PII) et renvoyée comme `None` — l'inscription au formulaire public n'échoue jamais pour autant. """ import logging import time from dataclasses import dataclass import httpx logger = logging.getLogger("app.sumup") _BASE_URL = "https://api.sumup.com" _TIMEOUT_SECONDS = 10.0 @dataclass(frozen=True) class SumUpCheckout: id: str status: str # PENDING / PAID / FAILED / EXPIRED (voir FeePaymentStatus) hosted_checkout_url: str | None # Transaction qui a réglé le checkout (renseignés par SumUp une fois # payé, `None` avant) : `transaction_code` est le code court affiché dans # le tableau de bord SumUp et sur ses reçus (ex. `TEENSK4W2K`) — c'est # lui que la coordination recherche pour rapprocher un paiement d'un # bénévole ; `transaction_id` est l'identifiant technique (API). transaction_code: str | None = None transaction_id: str | None = None def _call(method: str, path: str, api_key: str, json: dict | None = None) -> dict | None: started = time.monotonic() try: resp = httpx.request( method, f"{_BASE_URL}{path}", json=json, headers={"Authorization": f"Bearer {api_key}"}, timeout=_TIMEOUT_SECONDS, ) resp.raise_for_status() data = resp.json() except httpx.HTTPStatusError as exc: # Le corps d'erreur SumUp (`error_code`/`message`, voir leur doc) dit # pourquoi l'appel est refusé (identifiants invalides, montant, # référence déjà utilisée…) — il ne contient que des codes, pas de # données du payeur. logger.warning( "Échec de l'appel API SumUp (%s %s) : HTTP %d %s", method, path, exc.response.status_code, _error_summary(exc.response), ) return None except (httpx.HTTPError, ValueError) as exc: logger.warning( "Échec de l'appel API SumUp (%s %s) : %s", method, path, exc.__class__.__name__ ) return None logger.debug( "API SumUp %s %s : OK en %d ms", method, path, (time.monotonic() - started) * 1000, ) return data def _error_summary(resp: httpx.Response) -> str: try: body = resp.json() except ValueError: return "" if isinstance(body, list): # SumUp renvoie parfois une liste d'erreurs body = body[0] if body else {} if not isinstance(body, dict): return "" return " ".join( str(body[key])[:200] for key in ("error_code", "message", "param") if body.get(key) ) def _to_checkout(data: dict) -> SumUpCheckout: transaction_code = data.get("transaction_code") transaction_id = data.get("transaction_id") if not transaction_code: # Selon la version de l'API, seuls les essais de paiement # (`transactions`) portent le code : on retient celui qui a abouti. successful = next( ( tx for tx in data.get("transactions") or [] if isinstance(tx, dict) and tx.get("status") == "SUCCESSFUL" ), None, ) if successful is not None: transaction_code = successful.get("transaction_code") transaction_id = transaction_id or successful.get("id") return SumUpCheckout( id=data["id"], status=data["status"], hosted_checkout_url=data.get("hosted_checkout_url"), transaction_code=transaction_code, transaction_id=transaction_id, ) def create_checkout( *, api_key: str, merchant_code: str, amount: float, currency: str, checkout_reference: str, description: str, return_url: str, redirect_url: str, ) -> SumUpCheckout | None: """Crée un checkout hébergé (`hosted_checkout.enabled=true`) : la page de paiement SumUp est à `hosted_checkout_url`. `None` en cas d'échec (réseau, identifiants invalides…). L'API de création n'accepte pas de métadonnées libres : `description` (« affichée dans les outils SumUp ») et `checkout_reference` sont les seuls champs du marchand repris sur la transaction — c'est donc par eux que passe le nom du bénévole (voir `app/api/forms.py: _checkout_description`).""" data = _call( "POST", "/v0.1/checkouts", api_key, json={ "checkout_reference": checkout_reference, "amount": amount, "currency": currency, "merchant_code": merchant_code, "description": description, "return_url": return_url, "redirect_url": redirect_url, "hosted_checkout": {"enabled": True}, }, ) return _to_checkout(data) if data is not None else None def get_checkout(*, api_key: str, checkout_id: str) -> SumUpCheckout | None: """Relit l'état réel d'un checkout — un webhook SumUp ne donne qu'un identifiant, jamais un statut de confiance en lui-même (voir la doc SumUp sur les webhooks et `app/api/forms.py:sumup_webhook`).""" data = _call("GET", f"/v0.1/checkouts/{checkout_id}", api_key) return _to_checkout(data) if data is not None else None