- Une ligne de log par requête (route modèle, statut, durée, code d'erreur, compte), identifiant X-Request-ID repris dans chaque log et affiché à l'utilisateur sous une erreur, exceptions non gérées tracées (500 JSON) - Journal d'accès d'uvicorn coupé : il exposait le jeton du formulaire public - Logs métier : paiement SumUp, connexion, import, plannings, solveur - Nom du bénévole dans la description du checkout SumUp - Code et identifiant de transaction SumUp enregistrés au paiement (migration 0023), affichés sur la fiche et dans l'export Excel Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
160 lines
5.5 KiB
Python
160 lines
5.5 KiB
Python
"""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
|