From d0fa1e109cb52f9dfb7dcb300e239b5a7b6f107a Mon Sep 17 00:00:00 2001
From: AdamThomas
Date: Mon, 12 Oct 2026 18:54:35 +0200
Subject: [PATCH] =?UTF-8?q?Logs=20de=20diagnostic=20et=20rapprochement=20S?=
=?UTF-8?q?umUp=20=E2=86=94=20b=C3=A9n=C3=A9vole?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 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
---
CLAUDE.md | 37 ++++-
backend/app/api/auth.py | 35 +++++
backend/app/api/forms.py | 116 +++++++++++++++-
backend/app/api/imports.py | 16 ++-
backend/app/api/notifications.py | 9 ++
backend/app/api/schemas.py | 4 +
backend/app/api/solver.py | 16 +++
backend/app/auth/deps.py | 6 +-
backend/app/exporting/xlsx.py | 4 +
backend/app/logging_config.py | 27 +++-
backend/app/main.py | 3 +
.../versions/0023_fee_transaction.py | 40 ++++++
backend/app/models/domain.py | 7 +
backend/app/request_logging.py | 130 ++++++++++++++++++
backend/app/sumup/client.py | 73 +++++++++-
backend/tests/test_api_volunteers.py | 21 +++
backend/tests/test_forms_api.py | 50 +++++++
backend/tests/test_request_logging.py | 78 +++++++++++
backend/tests/test_sumup_client.py | 59 ++++++++
frontend/src/api/http-client.ts | 8 +-
frontend/src/api/mock/dataset.ts | 4 +
frontend/src/api/mock/mock-client.ts | 8 ++
frontend/src/api/schema.ts | 6 +
frontend/src/api/types.ts | 3 +
frontend/src/components/state.tsx | 5 +
frontend/src/i18n/locales/fr.json | 7 +
.../src/pages/config/ConfigVolunteers.tsx | 42 +++++-
27 files changed, 798 insertions(+), 16 deletions(-)
create mode 100644 backend/app/migrations/versions/0023_fee_transaction.py
create mode 100644 backend/app/request_logging.py
create mode 100644 backend/tests/test_request_logging.py
create mode 100644 backend/tests/test_sumup_client.py
diff --git a/CLAUDE.md b/CLAUDE.md
index 5cda45d..2644d41 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -205,8 +205,8 @@ les tables sauf les tables d'association.
- **Export bénévoles** (Excel, `app/exporting/xlsx.py` + `GET
/editions/{id}/volunteers/export`, COORDINATION) : une ligne par
bénévole, mêmes champs que la fiche (coordonnées, vœux, cotisation,
- mécénat) — les vœux (postes/artistes/compagnons) sont résolus en noms
- lisibles, jamais en UUID. `openpyxl`, déjà utilisé en lecture par l'import
+ mécénat, transaction SumUp — code et identifiant) — les vœux (postes/
+ artistes/compagnons) sont résolus en noms lisibles, jamais en UUID. `openpyxl`, déjà utilisé en lecture par l'import
(`app/importing/`), cette fois en écriture. Bouton « Exporter en Excel »
sur l'écran de configuration des bénévoles (`ConfigVolunteers.tsx`), même
mécanique de téléchargement que les badges/feuilles de poste (blob →
@@ -358,7 +358,17 @@ les tables sauf les tables d'association.
ou supprimée ensuite. `Volunteer.fee_checkout_id` (identifiant SumUp,
jamais exposé) et `fee_payment_status` (`PENDING`/`PAID`/`FAILED`/
`EXPIRED`, `NULL` tant qu'aucun checkout n'a été créé) suivent l'état ;
- `fee_paid_at` est posé à la première confirmation `PAID`. Une fois promue,
+ `fee_paid_at` est posé à la première confirmation `PAID`, avec la
+ transaction SumUp qui a réglé le checkout : `fee_transaction_code` (code
+ court du tableau de bord SumUp et de ses reçus, ex. `TEENSK4W2K` — lu sur
+ le checkout, ou à défaut sur son essai `SUCCESSFUL` dans `transactions`)
+ et `fee_transaction_id` (identifiant technique) — exposés sur la fiche
+ (section « Cotisation ») et dans l'export Excel, pour le rapprochement
+ comptable. Dans l'autre sens, l'API de création de checkout n'ayant
+ **aucun champ de métadonnées libre**, c'est la `description` du checkout
+ (« affichée dans les outils SumUp ») qui porte le nom du bénévole :
+ `Prénom Nom — libellé — édition AAAA` (`_checkout_description` ; donnée
+ transmise au prestataire de paiement, pas un log). Une fois promue,
la ligne `PendingFormSubmission` correspondante voit ses champs nominatifs
vidés (RGPD : l'information ne vit plus que sur `Volunteer`) ; une ligne
jamais payée est purgée après 48 h sans être relancée
@@ -724,7 +734,8 @@ l'étape 21.)*
│ │ ├── main.py # instance FastAPI
│ │ ├── config.py # settings (pydantic-settings)
│ │ ├── db.py # engine + session
-│ │ ├── logging_config.py # RGPD : pas de PII dans les logs
+│ │ ├── logging_config.py # RGPD : pas de PII dans les logs ; identifiant de requête
+│ ├── request_logging.py # une ligne de log par requête, X-Request-ID, erreurs 500
│ │ ├── api/ # routers + schemas Pydantic + deps
│ │ │ # health, auth, accounts, editions,
│ │ │ # day_parts, imports, artists, volunteers,
@@ -785,6 +796,23 @@ Aucune dépendance locale requise hors Docker.
+ `locales/fr.json`. Frontend : fichier i18n dédié. Vrai même en monolingue.
- **RGPD :**
- Pas de log des noms, emails, téléphones. On logue des identifiants.
+- **Logs** (`app/logging_config.py`, `app/request_logging.py`) : une ligne
+ par requête HTTP (`app.request` — méthode, route **modèle** + ses
+ identifiants, jamais l'URL brute ni un paramètre `*token*`, statut, durée,
+ code d'erreur métier `detail.code`, compte connecté) ; INFO / WARNING
+ (4xx) / ERROR (5xx), `/health` en DEBUG. Chaque requête a un identifiant
+ (`X-Request-ID`, repris s'il est fourni et bien formé) préfixé à toutes
+ ses lignes de log, tâches de fond comprises, et affiché à l'utilisateur
+ sous une erreur (`ErrorNote` : « Référence à communiquer ») — `grep` de
+ cette référence dans les logs du conteneur pour diagnostiquer. Erreur 422
+ de validation : emplacement + type, jamais la valeur saisie. Exception non
+ gérée : trace complète + `500 {detail: {code: "error.internal",
+ request_id}}`. Journal d'accès d'uvicorn **désactivé** (il écrivait la
+ chaîne de requête, donc le `access_token` du formulaire public). Logs
+ métier sur les parcours opaques : paiement SumUp (inscription en attente,
+ checkout créé/refusé avec le code d'erreur SumUp, changements de statut,
+ promotion, webhook), connexion (cause réelle d'un refus, côté logs
+ seulement), import, envoi de plannings, solveur (durées).
- `Edition.retention_days` : durée de rétention paramétrable.
- Endpoint de suppression complète d'un bénévole : *hard delete* en cascade.
- **Dates et heures :** stockées naïves, interprétées en heure locale
@@ -875,6 +903,7 @@ Aucune dépendance locale requise hors Docker.
| 40 | Écran Statistiques (`/statistiques`, COORDINATION, `GET /editions/{id}/stats`) : inscriptions (nombre, évolution cumulée), réponses au formulaire, artistes demandés, cotisations (en ligne / sur place / aucune, par type, mécénat, paiements jamais aboutis), besoins par journée de festival face aux disponibilités déclarées et aux affectations, places pourvues et vœux par poste — graphiques SVG maison avec infobulles et vues tableau | à valider |
| 41 | Début de « journée de festival » indépendant de l'ordre des créneaux types : début du plus long creux qu'ils laissent sur 24 h (`validation.engine.day_boundary`, miroir `lib/planning.ts:dayBoundaryFromDayParts`). Avant, un créneau type d'ordre 0 déplacé à 21:00 faisait commencer chaque journée des grilles (besoins, vue bénévoles) à 21:00, jusqu'à 03:00 le lendemain | à valider |
| 42 | Retrait des disponibilités déclarées (table `availability` supprimée, migration `0022` ; API, éditeur de la fiche bénévole, badge de couverture du panneau d'affectation, colonnes des statistiques) et des limites de temps dans la journée (règles `OUTSIDE_AVAILABILITY`, `DAILY_HOURS_EXCEEDED`, `INSUFFICIENT_REST`, malus de disponibilité et de repos du solveur, filtre « Surchargés » de la vue bénévoles) — seul le chevauchement reste contrôlé. La case « je suis disponible » du formulaire public est conservée | à valider |
+| 43 | Logs de diagnostic (ligne par requête avec route modèle, statut, durée, code d'erreur et compte ; identifiant `X-Request-ID` repris dans chaque log et affiché à l'utilisateur sous une erreur ; exceptions non gérées tracées ; journal d'accès d'uvicorn coupé car il exposait le jeton du formulaire public ; logs métier SumUp, connexion, import, plannings, solveur) et rapprochement SumUp ↔ bénévole : nom du bénévole dans la description du checkout (pas de métadonnées libres dans l'API), code et identifiant de transaction enregistrés au paiement (`Volunteer.fee_transaction_code`/`fee_transaction_id`, migration `0023`), affichés sur la fiche et dans l'export Excel | à valider |
**Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur
de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans +
diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py
index 095b346..e09e2dc 100644
--- a/backend/app/api/auth.py
+++ b/backend/app/api/auth.py
@@ -84,7 +84,17 @@ def login(
or account.password_hash is None
or not verify_password(payload.password, account.password_hash)
):
+ # Cause réelle dans les logs seulement — la réponse reste la même
+ # (pas de divulgation). Jamais l'email saisi.
+ if account is None:
+ reason = "aucun compte pour cet email"
+ elif account.password_hash is None:
+ reason = f"compte {account.id} sans mot de passe (lien magique)"
+ else:
+ reason = f"mot de passe incorrect pour le compte {account.id}"
+ logger.info("Connexion refusée (édition %s) : %s", edition_id, reason)
raise _invalid_credentials()
+ logger.info("Connexion par mot de passe : compte %s (édition %s)", account.id, edition_id)
return _issue(account)
@@ -135,7 +145,19 @@ def request_magic_link(
tâche de fond (une erreur SMTP est journalisée, pas remontée)."""
magic_link_limiter.hit(f"{edition_id}:{payload.email.strip().lower()}")
account = _account_by_email(session, edition_id, payload.email)
+ if account is None:
+ logger.info(
+ "Lien magique demandé sans compte correspondant (édition %s) — rien envoyé "
+ "(un email sur une fiche bénévole ne crée pas d'accès)",
+ edition_id,
+ )
+ elif account.password_hash is not None:
+ logger.info(
+ "Lien magique demandé pour le compte %s, qui a un mot de passe — rien envoyé",
+ account.id,
+ )
if account is not None and account.password_hash is None:
+ logger.info("Lien magique émis pour le compte %s (édition %s)", account.id, edition_id)
settings = get_settings()
raw = issue_magic_link_token(session, account)
session.commit()
@@ -172,13 +194,26 @@ def verify_magic_link(
or token.used_at is not None
or token.expires_at < local_now()
):
+ if token is None:
+ reason = "jeton inconnu"
+ elif token.used_at is not None:
+ reason = f"jeton déjà utilisé (compte {token.account_id})"
+ else:
+ reason = f"jeton expiré (compte {token.account_id})"
+ logger.info("Lien magique refusé (édition %s) : %s", edition_id, reason)
raise _invalid_token()
account = session.get(Account, token.account_id)
if account is None or account.edition_id != edition_id:
+ logger.info(
+ "Lien magique refusé (édition %s) : compte %s absent ou d'une autre édition",
+ edition_id,
+ token.account_id,
+ )
raise _invalid_token()
token.used_at = local_now()
session.commit()
session.refresh(account)
+ logger.info("Connexion par lien magique : compte %s (édition %s)", account.id, edition_id)
return _issue(account)
diff --git a/backend/app/api/forms.py b/backend/app/api/forms.py
index 8e52a66..bdec908 100644
--- a/backend/app/api/forms.py
+++ b/backend/app/api/forms.py
@@ -718,6 +718,11 @@ async def submit_public_form(
set_artists(session, volunteer, artist_ids)
session.commit()
session.refresh(volunteer)
+ logger.info(
+ "Formulaire public : bénévole %s créé (édition %s, sans paiement en ligne)",
+ volunteer.id,
+ edition.id,
+ )
if volunteer.email:
subject, body = render_registration_confirmation(first_name=volunteer.first_name)
background.add_task(
@@ -758,6 +763,14 @@ async def submit_public_form(
session.add(pending)
session.commit()
session.refresh(pending)
+ logger.info(
+ "Formulaire public : inscription en attente de paiement %s (édition %s, "
+ "montant %.2f, mécénat=%s)",
+ pending.id,
+ edition.id,
+ pending.payment_amount,
+ pending.requires_mecenat_info,
+ )
if pending.requires_mecenat_info:
return FormSubmissionResult(pending_token=pending.id, requires_mecenat_info=True)
@@ -783,19 +796,53 @@ def _create_pending_checkout(
amount=pending.payment_amount,
currency="EUR",
checkout_reference=f"{pending.id}-{uuid.uuid4().hex[:8]}",
- description=f"{pending.payment_label} — édition {edition.year}",
+ description=_checkout_description(edition, pending),
return_url=f"{base}/api/editions/{edition.id}/form/public/sumup-webhook",
redirect_url=f"{base}/inscription/{template.access_token}?paiement={pending.id}",
)
if checkout is None:
+ logger.warning(
+ "Checkout SumUp non créé pour l'inscription en attente %s (édition %s) — "
+ "le bénévole devra réessayer",
+ pending.id,
+ edition.id,
+ )
return None
pending.checkout_id = checkout.id
pending.checkout_status = FeePaymentStatus.PENDING
session.add(pending)
session.commit()
+ logger.info(
+ "Checkout SumUp %s créé pour l'inscription en attente %s (édition %s)",
+ checkout.id,
+ pending.id,
+ edition.id,
+ )
+ if not checkout.hosted_checkout_url:
+ logger.warning(
+ "Checkout SumUp %s sans hosted_checkout_url — le paiement hébergé "
+ "est-il activé sur le compte marchand ?",
+ checkout.id,
+ )
return checkout.hosted_checkout_url
+# Longueur prudente : la doc SumUp ne fixe pas de maximum pour `description`.
+_CHECKOUT_DESCRIPTION_MAX = 255
+
+
+def _checkout_description(edition: Edition, pending: PendingFormSubmission) -> str:
+ """Libellé de la transaction côté SumUp (tableau de bord, exports) : le
+ nom du bénévole en tête, pour qu'un paiement s'y rapproche d'un coup
+ d'œil de la bonne personne — l'API de création de checkout n'a pas de
+ champ de métadonnées libre (voir `app/sumup/client.py:create_checkout`).
+ Donnée transmise au prestataire de paiement, pas un log : la règle RGPD
+ de `app/logging_config.py` ne s'applique pas ici."""
+ name = " ".join(p for p in (pending.first_name, pending.last_name) if p)
+ parts = [name, pending.payment_label, f"édition {edition.year}"]
+ return " — ".join(p for p in parts if p)[:_CHECKOUT_DESCRIPTION_MAX]
+
+
def _promote_pending(
session: Session,
edition: Edition,
@@ -824,6 +871,8 @@ def _promote_pending(
fee_checkout_id=checkout.id,
fee_payment_status=FeePaymentStatus.PAID,
fee_paid_at=local_now(),
+ fee_transaction_code=checkout.transaction_code,
+ fee_transaction_id=checkout.transaction_id,
mecenat_address=pending.mecenat_address,
mecenat_city=pending.mecenat_city,
mecenat_postal_code=pending.mecenat_postal_code,
@@ -866,6 +915,22 @@ def _promote_pending(
pending.mecenat_country = None
session.add(pending)
session.commit()
+ logger.info(
+ "Paiement confirmé : inscription en attente %s → bénévole %s (édition %s, "
+ "checkout %s, transaction %s, reçu mécénat %s)",
+ pending.id,
+ volunteer.id,
+ edition.id,
+ checkout.id,
+ checkout.transaction_code or "inconnue",
+ volunteer.mecenat_receipt_number or "-",
+ )
+ if not checkout.transaction_code:
+ logger.warning(
+ "Checkout SumUp %s payé sans code de transaction renvoyé — rapprochement "
+ "à faire depuis le tableau de bord SumUp",
+ checkout.id,
+ )
if volunteer.email:
# Paiement « mécénat » (adresse postale saisie) : le cerfa rempli part
@@ -906,7 +971,10 @@ def _send_paid_confirmation(
pdf = generate_mecenat_receipts_pdf([receipt])
attachments.append((pdf, "application/pdf", RECEIPT_FILENAME))
except Exception:
- logger.exception("Reçu fiscal mécénat non généré — email envoyé sans pièce jointe")
+ logger.exception(
+ "Reçu fiscal mécénat n° %s non généré — email envoyé sans pièce jointe",
+ receipt.number or "?",
+ )
send(to, subject, body, logo=logo, attachments=attachments)
@@ -925,10 +993,21 @@ def _apply_pending_checkout_result(
try:
new_status = FeePaymentStatus(checkout.status)
except ValueError:
+ logger.warning(
+ "Checkout SumUp %s : statut inconnu %r ignoré", checkout.id, checkout.status
+ )
return
if new_status == FeePaymentStatus.PAID:
_promote_pending(session, edition, pending, checkout, background)
else:
+ if new_status != pending.checkout_status:
+ logger.info(
+ "Checkout SumUp %s (inscription en attente %s) : %s → %s",
+ checkout.id,
+ pending.id,
+ pending.checkout_status,
+ new_status,
+ )
pending.checkout_status = new_status
session.add(pending)
session.commit()
@@ -1043,6 +1122,12 @@ def retry_pending_payment(
)
template = _get_or_create_template(session, edition.id)
+ logger.info(
+ "Relance du paiement de l'inscription en attente %s (édition %s, statut %s)",
+ pending.id,
+ edition.id,
+ pending.checkout_status,
+ )
checkout_url = _create_pending_checkout(session, edition, template, pending)
return FormSubmissionResult(pending_token=pending.id, sumup_checkout_url=checkout_url)
@@ -1059,13 +1144,23 @@ def sumup_webhook(
identifiant : jamais fait confiance sans relire l'état réel via l'API
(voir `app/sumup/client.py:get_checkout`). Répond toujours `200`, y
compris sur un événement inconnu — SumUp réessaie sinon inutilement."""
+ logger.info(
+ "Webhook SumUp reçu (édition %s, événement %s, checkout %s)",
+ edition.id,
+ payload.event_type,
+ payload.id,
+ )
template = _get_or_create_template(session, edition.id)
if not template.sumup_api_key:
+ logger.warning(
+ "Webhook SumUp ignoré : aucune clé API SumUp configurée (édition %s)",
+ edition.id,
+ )
return Response(status_code=status.HTTP_200_OK)
checkout = get_checkout(api_key=template.sumup_api_key, checkout_id=payload.id)
if checkout is None:
- logger.warning("Webhook SumUp : relecture du checkout impossible.")
+ logger.warning("Webhook SumUp : relecture du checkout %s impossible.", payload.id)
return Response(status_code=status.HTTP_200_OK)
pending = session.exec(
@@ -1094,6 +1189,21 @@ def sumup_webhook(
volunteer.fee_payment_status = new_status
if new_status == FeePaymentStatus.PAID and volunteer.fee_paid_at is None:
volunteer.fee_paid_at = local_now()
+ if checkout.transaction_code and not volunteer.fee_transaction_code:
+ volunteer.fee_transaction_code = checkout.transaction_code
+ volunteer.fee_transaction_id = checkout.transaction_id
session.add(volunteer)
session.commit()
+ logger.info(
+ "Webhook SumUp : bénévole %s (checkout %s) → %s",
+ volunteer.id,
+ payload.id,
+ new_status,
+ )
+ return Response(status_code=status.HTTP_200_OK)
+ logger.warning(
+ "Webhook SumUp : checkout %s inconnu dans l'édition %s (inscription purgée ?)",
+ payload.id,
+ edition.id,
+ )
return Response(status_code=status.HTTP_200_OK)
diff --git a/backend/app/api/imports.py b/backend/app/api/imports.py
index b193e87..7c4c961 100644
--- a/backend/app/api/imports.py
+++ b/backend/app/api/imports.py
@@ -1,5 +1,7 @@
"""Endpoints d'import : analyse puis exécution."""
+import logging
+
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
from pydantic import ValidationError
from sqlmodel import Session
@@ -17,6 +19,8 @@ from app.importing.schemas import (
from app.importing.service import run_import, suggest_mapping, validate_mapping
from app.models import Account, Edition
+logger = logging.getLogger("app.importing")
+
router = APIRouter(prefix="/editions/{edition_id}/import", tags=["import"])
_PREVIEW_ROWS = 5
@@ -107,9 +111,19 @@ async def execute(
},
)
- return run_import(
+ report = run_import(
session=session,
edition=edition,
table=table,
mapping=parsed_mapping,
)
+ logger.info(
+ "Import %s (édition %s) : %d lignes — %d créés, %d mis à jour, %d ignorés",
+ "simulé" if report.dry_run else "exécuté",
+ edition.id,
+ report.total_rows,
+ report.created,
+ report.updated,
+ report.skipped,
+ )
+ return report
diff --git a/backend/app/api/notifications.py b/backend/app/api/notifications.py
index 0cea51d..6c1814c 100644
--- a/backend/app/api/notifications.py
+++ b/backend/app/api/notifications.py
@@ -1,5 +1,6 @@
"""Envoi d'emails d'information aux bénévoles (récapitulatif de planning)."""
+import logging
import uuid
from collections import defaultdict
@@ -18,6 +19,8 @@ from app.email import edition_logo, render_planning, send_many
from app.i18n import t
from app.models import Account, Assignment, Edition, Position, Shift, Volunteer
+logger = logging.getLogger("app.notifications")
+
router = APIRouter(
prefix="/editions/{edition_id}/notifications", tags=["notifications"]
)
@@ -112,6 +115,12 @@ def send_planning_notification(
recipients.append(volunteer.id)
session.commit() # persiste les éventuels MagicLinkToken émis
+ logger.info(
+ "Récapitulatif de planning (édition %s) : %d emails en file, %d bénévoles sans email",
+ edition.id,
+ len(items),
+ skipped_no_email,
+ )
background.add_task(send_many, items, logo=edition_logo(session, edition.id))
return PlanningNotificationResult(
queued=len(items),
diff --git a/backend/app/api/schemas.py b/backend/app/api/schemas.py
index 757fe7a..a026a28 100644
--- a/backend/app/api/schemas.py
+++ b/backend/app/api/schemas.py
@@ -102,6 +102,10 @@ class VolunteerRead(ApiModel):
fee_amount: float | None
fee_payment_status: FeePaymentStatus | None
fee_paid_at: dt.datetime | None
+ # Transaction SumUp ayant réglé la cotisation (code du tableau de bord
+ # SumUp + identifiant technique) — `None` tant que non payée.
+ fee_transaction_code: str | None
+ fee_transaction_id: str | None
# Coordonnées pour un reçu fiscal (cerfa) — seulement si payé via un
# type de paiement « mécénat » (voir `FormPaymentOption.requires_mecenat_info`).
mecenat_address: str | None
diff --git a/backend/app/api/solver.py b/backend/app/api/solver.py
index d6d1f5f..c5dfb3a 100644
--- a/backend/app/api/solver.py
+++ b/backend/app/api/solver.py
@@ -2,6 +2,8 @@
coordination affecte elle-même, dans l'écran habituel, en tenant compte des
bénévoles remontés en tête de liste."""
+import logging
+import time
import uuid
from fastapi import APIRouter, Depends
@@ -14,6 +16,8 @@ from app.db import get_session
from app.models import Account, Edition
from app.solver import build_solver_input, solve
+logger = logging.getLogger("app.solver")
+
router = APIRouter(prefix="/editions/{edition_id}/solver", tags=["solver"])
@@ -23,8 +27,20 @@ def run_solver(
_guard: Account = Depends(require_staff),
session: Session = Depends(get_session),
) -> SolverRunResult:
+ started = time.monotonic()
data = build_solver_input(session=session, edition=edition)
+ loaded = time.monotonic()
solution = solve(data)
+ logger.info(
+ "Solveur (édition %s) : %d créneaux, %d bénévoles, %d suggestions — "
+ "chargement %d ms, résolution %d ms",
+ edition.id,
+ len(data.shifts),
+ len(data.volunteers),
+ len(solution.suggestions),
+ (loaded - started) * 1000,
+ (time.monotonic() - loaded) * 1000,
+ )
return SolverRunResult(
suggestions=[
SolverSuggestion(
diff --git a/backend/app/auth/deps.py b/backend/app/auth/deps.py
index 5c72749..ea2cdd7 100644
--- a/backend/app/auth/deps.py
+++ b/backend/app/auth/deps.py
@@ -4,7 +4,7 @@ import uuid
from collections.abc import Callable
import jwt
-from fastapi import Depends, HTTPException, status
+from fastapi import Depends, HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlmodel import Session
@@ -31,6 +31,7 @@ def _forbidden() -> HTTPException:
def get_current_account(
+ request: Request,
credentials: HTTPAuthorizationCredentials | None = Depends(_bearer),
session: Session = Depends(get_session),
) -> Account:
@@ -44,6 +45,9 @@ def get_current_account(
account = session.get(Account, account_id)
if account is None:
raise _unauthorized()
+ # Repris sur la ligne d'accès de la requête (`app/request_logging.py`) :
+ # qui a rencontré l'erreur, par identifiant seulement.
+ request.state.account_id = account.id
return account
diff --git a/backend/app/exporting/xlsx.py b/backend/app/exporting/xlsx.py
index eda91ef..4d4bb4e 100644
--- a/backend/app/exporting/xlsx.py
+++ b/backend/app/exporting/xlsx.py
@@ -29,6 +29,8 @@ _HEADER = [
"Montant (€)",
"Statut du paiement",
"Payée le",
+ "Transaction SumUp (code)",
+ "Transaction SumUp (identifiant)",
"Mécénat — adresse",
"Mécénat — ville",
"Mécénat — code postal",
@@ -114,6 +116,8 @@ def generate_volunteers_xlsx(
v.fee_amount if v.fee_amount is not None else "",
_FEE_STATUS_LABELS.get(v.fee_payment_status, ""),
_fmt_dt(v.fee_paid_at),
+ _safe_text(v.fee_transaction_code or ""),
+ _safe_text(v.fee_transaction_id or ""),
_safe_text(v.mecenat_address or ""),
_safe_text(v.mecenat_city or ""),
_safe_text(v.mecenat_postal_code or ""),
diff --git a/backend/app/logging_config.py b/backend/app/logging_config.py
index 4d35cc9..5fbcf80 100644
--- a/backend/app/logging_config.py
+++ b/backend/app/logging_config.py
@@ -1,4 +1,5 @@
import logging
+from contextvars import ContextVar
from app.config import get_settings
@@ -7,6 +8,19 @@ from app.config import get_settings
# téléphone dans les logs. On journalise des identifiants (UUID/entier) et des
# codes. Toute nouvelle instruction de log doit respecter cette règle.
+# Identifiant de la requête HTTP en cours (voir `app/request_logging.py`),
+# repris sur chaque ligne de log émise pendant son traitement — y compris
+# dans ses tâches de fond (`BackgroundTasks`, même contexte) — pour retrouver
+# d'un `grep` tout ce qui s'est passé lors de l'erreur qu'un utilisateur
+# signale (la référence lui est affichée, voir `frontend/.../state.tsx`).
+request_id_var: ContextVar[str] = ContextVar("request_id", default="-")
+
+
+class RequestIdFilter(logging.Filter):
+ def filter(self, record: logging.LogRecord) -> bool:
+ record.request_id = request_id_var.get()
+ return True
+
def configure_logging() -> None:
# `force=True` : on réinstalle le handler racine même si l'import de
@@ -14,6 +28,17 @@ def configure_logging() -> None:
# sinon les logs `app.*` n'apparaissent pas dans la sortie du conteneur.
logging.basicConfig(
level=get_settings().log_level,
- format="%(asctime)s %(levelname)s %(name)s %(message)s",
+ format="%(asctime)s %(levelname)s [%(request_id)s] %(name)s %(message)s",
force=True,
)
+ # Filtre posé sur le handler (pas sur un logger) : il voit aussi les
+ # lignes des bibliothèques (uvicorn, sqlalchemy…) propagées à la racine.
+ for handler in logging.getLogger().handlers:
+ handler.addFilter(RequestIdFilter())
+ # Journal d'accès d'uvicorn remplacé par `app/request_logging.py` : le
+ # sien écrit l'URL complète, chaîne de requête comprise — donc le jeton
+ # `access_token` du formulaire public (CLAUDE.md § 2, `FormTemplate`).
+ logging.getLogger("uvicorn.access").disabled = True
+ # Bavard au niveau INFO (une ligne par appel SumUp/hCaptcha), sans
+ # intérêt à côté des logs de l'appli.
+ logging.getLogger("httpx").setLevel(logging.WARNING)
diff --git a/backend/app/main.py b/backend/app/main.py
index 49b68f3..6d590a4 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -23,6 +23,7 @@ from app.api.stats import router as stats_router
from app.api.volunteers import router as volunteer_router
from app.config import get_settings
from app.logging_config import configure_logging
+from app.request_logging import REQUEST_ID_HEADER, install_request_logging
configure_logging()
@@ -56,7 +57,9 @@ app.add_middleware(
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
+ expose_headers=[REQUEST_ID_HEADER],
)
+install_request_logging(app)
app.include_router(health_router)
app.include_router(auth_router)
diff --git a/backend/app/migrations/versions/0023_fee_transaction.py b/backend/app/migrations/versions/0023_fee_transaction.py
new file mode 100644
index 0000000..143fb7a
--- /dev/null
+++ b/backend/app/migrations/versions/0023_fee_transaction.py
@@ -0,0 +1,40 @@
+"""Transaction SumUp de la cotisation
+
+Revision ID: 0023_fee_transaction
+Revises: 0022_drop_availability
+Create Date: 2026-10-12
+
+`volunteer.fee_transaction_code` (code affiché dans le tableau de bord SumUp,
+ex. `TEENSK4W2K`) et `fee_transaction_id` (identifiant technique) : la
+transaction qui a réglé la cotisation, posée à la confirmation du paiement —
+voir `app/api/forms.py:_promote_pending`. Les bénévoles déjà payés restent
+à `NULL`.
+"""
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "0023_fee_transaction"
+down_revision: str | None = "0022_drop_availability"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+ op.add_column(
+ "volunteer", sa.Column("fee_transaction_code", sa.String(), nullable=True)
+ )
+ op.create_index(
+ "ix_volunteer_fee_transaction_code", "volunteer", ["fee_transaction_code"]
+ )
+ op.add_column(
+ "volunteer", sa.Column("fee_transaction_id", sa.String(), nullable=True)
+ )
+
+
+def downgrade() -> None:
+ op.drop_column("volunteer", "fee_transaction_id")
+ op.drop_index("ix_volunteer_fee_transaction_code", table_name="volunteer")
+ op.drop_column("volunteer", "fee_transaction_code")
diff --git a/backend/app/models/domain.py b/backend/app/models/domain.py
index 95ad544..18e665e 100644
--- a/backend/app/models/domain.py
+++ b/backend/app/models/domain.py
@@ -211,6 +211,13 @@ class Volunteer(TimestampMixin, table=True):
sa_column=Column(SAEnum(FeePaymentStatus, native_enum=False), nullable=True),
)
fee_paid_at: dt.datetime | None = None
+ # Transaction SumUp qui a réglé la cotisation (`app/sumup/client.py:
+ # SumUpCheckout`), posée à la confirmation du paiement : le code court
+ # (`TEENSK4W2K`) est celui du tableau de bord SumUp et de ses reçus —
+ # rapprochement comptable paiement ↔ bénévole. Exposés (fiche, export
+ # Excel), contrairement à `fee_checkout_id`.
+ fee_transaction_code: str | None = Field(default=None, index=True)
+ fee_transaction_id: str | None = None
# Coordonnées collectées uniquement pour un type de paiement « mécénat »
# (`FormPaymentOption.requires_mecenat_info`), avant le paiement (voir
# `PendingFormSubmission` et `app/api/forms.py:submit_mecenat_info`) —
diff --git a/backend/app/request_logging.py b/backend/app/request_logging.py
new file mode 100644
index 0000000..8899ad5
--- /dev/null
+++ b/backend/app/request_logging.py
@@ -0,0 +1,130 @@
+"""Journal des requêtes HTTP — une ligne par requête, pour diagnostiquer
+après coup une erreur rencontrée par un utilisateur (voir CLAUDE.md § 8,
+RGPD : identifiants et codes seulement).
+
+- Chaque requête reçoit un identifiant (`X-Request-ID`, repris s'il est
+ fourni et bien formé, généré sinon), renvoyé dans l'en-tête de la réponse
+ et préfixé à toutes les lignes de log émises pendant son traitement
+ (`app/logging_config.py:request_id_var`).
+- La ligne d'accès donne la route **modèle** (`/editions/{edition_id}/…`) et
+ ses paramètres d'identifiants, jamais l'URL brute : la chaîne de requête
+ peut porter un jeton (`access_token` du formulaire public), et les
+ paramètres de chemin nommés `*token*` (inscription en attente) sont omis.
+- Code d'erreur métier (`detail.code`) et compte connecté ajoutés quand ils
+ existent ; détails d'une erreur de validation 422 (emplacement + type,
+ jamais la valeur saisie).
+- Une exception non gérée est journalisée avec sa trace et renvoie un 500
+ JSON `{detail: {code: "error.internal", request_id}}` plutôt qu'un texte
+ brut.
+"""
+
+import logging
+import re
+import time
+import uuid
+
+from fastapi import FastAPI, HTTPException, Request
+from fastapi.exception_handlers import (
+ http_exception_handler,
+ request_validation_exception_handler,
+)
+from fastapi.exceptions import RequestValidationError
+from fastapi.responses import JSONResponse
+from starlette.exceptions import HTTPException as StarletteHTTPException
+
+from app.logging_config import request_id_var
+
+logger = logging.getLogger("app.request")
+
+REQUEST_ID_HEADER = "X-Request-ID"
+_INCOMING_ID = re.compile(r"^[A-Za-z0-9-]{8,64}$")
+# Sondes de santé (Docker, Caddy) : pas une ligne INFO toutes les 10 s.
+_QUIET_PATHS = {"/health"}
+
+
+def _route_label(request: Request) -> str:
+ route = request.scope.get("route")
+ template = getattr(route, "path", None) or ""
+ params = {
+ key: value
+ for key, value in (request.scope.get("path_params") or {}).items()
+ if "token" not in key
+ }
+ if not params:
+ return template
+ return f"{template} " + " ".join(f"{k}={v}" for k, v in params.items())
+
+
+def install_request_logging(app: FastAPI) -> None:
+ @app.middleware("http")
+ async def log_requests(request: Request, call_next):
+ incoming = request.headers.get(REQUEST_ID_HEADER, "")
+ request_id = incoming if _INCOMING_ID.match(incoming) else uuid.uuid4().hex[:12]
+ reset = request_id_var.set(request_id)
+ started = time.monotonic()
+ try:
+ try:
+ response = await call_next(request)
+ except Exception:
+ logger.exception(
+ "Exception non gérée sur %s %s", request.method, _route_label(request)
+ )
+ response = JSONResponse(
+ status_code=500,
+ content={"detail": {"code": "error.internal", "request_id": request_id}},
+ )
+ duration_ms = (time.monotonic() - started) * 1000
+ response.headers[REQUEST_ID_HEADER] = request_id
+
+ status_code = response.status_code
+ if status_code >= 500:
+ level = logging.ERROR
+ elif status_code >= 400:
+ level = logging.WARNING
+ elif request.url.path in _QUIET_PATHS or request.method == "OPTIONS":
+ level = logging.DEBUG
+ else:
+ level = logging.INFO
+ extras = []
+ error_code = getattr(request.state, "error_code", None)
+ if error_code:
+ extras.append(f"code={error_code}")
+ account_id = getattr(request.state, "account_id", None)
+ if account_id:
+ extras.append(f"compte={account_id}")
+ logger.log(
+ level,
+ "%s %s → %d (%d ms)%s",
+ request.method,
+ _route_label(request),
+ status_code,
+ duration_ms,
+ (" " + " ".join(extras)) if extras else "",
+ )
+ return response
+ finally:
+ request_id_var.reset(reset)
+
+ @app.exception_handler(StarletteHTTPException)
+ async def log_http_exception(request: Request, exc: HTTPException):
+ detail = exc.detail
+ if isinstance(detail, dict) and detail.get("code"):
+ request.state.error_code = detail["code"]
+ violations = detail.get("violations")
+ if violations:
+ # Codes de violation seulement (`SHIFT_OVERLAP`…) : les
+ # paramètres peuvent porter des horaires, pas utiles ici.
+ request.state.error_code += " violations=" + ",".join(
+ sorted({str(v.get("code")) for v in violations if isinstance(v, dict)})
+ )
+ return await http_exception_handler(request, exc)
+
+ @app.exception_handler(RequestValidationError)
+ async def log_validation_error(request: Request, exc: RequestValidationError):
+ # Emplacement + type d'erreur, jamais `input` (la valeur saisie, qui
+ # peut être un nom, un email…).
+ request.state.error_code = "validation " + "; ".join(
+ f"{'.'.join(str(p) for p in err.get('loc', ()))}:{err.get('type')}"
+ for err in exc.errors()[:10]
+ )
+ return await request_validation_exception_handler(request, exc)
diff --git a/backend/app/sumup/client.py b/backend/app/sumup/client.py
index acfe677..039c2db 100644
--- a/backend/app/sumup/client.py
+++ b/backend/app/sumup/client.py
@@ -9,6 +9,7 @@ l'inscription au formulaire public n'échoue jamais pour autant.
"""
import logging
+import time
from dataclasses import dataclass
import httpx
@@ -24,9 +25,17 @@ 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,
@@ -36,19 +45,71 @@ def _call(method: str, path: str, api_key: str, json: dict | None = None) -> dic
timeout=_TIMEOUT_SECONDS,
)
resp.raise_for_status()
- return resp.json()
- except httpx.HTTPError as exc:
+ 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,
)
@@ -65,7 +126,13 @@ def create_checkout(
) -> 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…)."""
+ 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",
diff --git a/backend/tests/test_api_volunteers.py b/backend/tests/test_api_volunteers.py
index 244ce12..60f14b8 100644
--- a/backend/tests/test_api_volunteers.py
+++ b/backend/tests/test_api_volunteers.py
@@ -588,3 +588,24 @@ def test_mecenat_receipts_is_coordinator_only(
lead = make_account(Role.LEAD, email="lead@e.fr")
resp = as_account(lead).get(_url(edition, "/mecenat-receipts"))
assert resp.status_code == 403
+
+
+def test_export_gives_the_sumup_transaction(client, edition, session):
+ volunteer = make_volunteer(session, edition, first_name="Sam", last_name="B")
+ _mark_mecenat_paid(session, volunteer)
+ volunteer.fee_transaction_code = "TEENSK4W2K"
+ volunteer.fee_transaction_id = "4ba8a7ba-0f61-4c8a-9b5c-2f3e0b4a8a11"
+ session.add(volunteer)
+ session.commit()
+
+ resp = client.get(_url(edition, "/export"))
+ rows = list(load_workbook(io.BytesIO(resp.content)).active.iter_rows(values_only=True))
+ code = rows[0].index("Transaction SumUp (code)")
+ ident = rows[0].index("Transaction SumUp (identifiant)")
+ assert rows[1][code] == "TEENSK4W2K"
+ assert rows[1][ident] == "4ba8a7ba-0f61-4c8a-9b5c-2f3e0b4a8a11"
+
+ fiche = client.get(_url(edition, f"/{volunteer.id}")).json()
+ assert fiche["fee_transaction_code"] == "TEENSK4W2K"
+ # L'identifiant du checkout, lui, reste interne.
+ assert "fee_checkout_id" not in fiche
diff --git a/backend/tests/test_forms_api.py b/backend/tests/test_forms_api.py
index 72348b5..aee5bce 100644
--- a/backend/tests/test_forms_api.py
+++ b/backend/tests/test_forms_api.py
@@ -716,6 +716,56 @@ def test_paying_promotes_the_pending_submission_to_a_volunteer(
assert fiche["willing_to_pay_fee"] is True
+def test_checkout_description_names_the_volunteer(raw_client, client, edition, monkeypatch):
+ # Pas de champ de métadonnées libre à la création d'un checkout SumUp :
+ # c'est la description (visible dans le tableau de bord SumUp) qui porte
+ # le nom, pour rapprocher la transaction du bénévole.
+ option_id = _configure_sumup(client, edition)
+ calls = []
+ monkeypatch.setattr(
+ "app.api.forms.create_checkout",
+ lambda **kw: calls.append(kw)
+ or SumUpCheckout(id="co_1", status="PENDING", hosted_checkout_url="https://x"),
+ )
+ _submit(
+ raw_client, edition,
+ first_name="alex", last_name="martin", hcaptcha_token="x",
+ payment_option_id=option_id,
+ )
+ assert calls[0]["description"] == (
+ f"Alex Martin — Cotisation classique — édition {edition.year}"
+ )
+
+
+def test_promotion_records_the_sumup_transaction(raw_client, client, edition, monkeypatch):
+ option_id = _configure_sumup(client, edition)
+ monkeypatch.setattr(
+ "app.api.forms.create_checkout",
+ lambda **kw: SumUpCheckout(id="co_1", status="PENDING", hosted_checkout_url="https://x"),
+ )
+ token = _submit(
+ raw_client, edition,
+ first_name="Alex", last_name="Martin", hcaptcha_token="x",
+ payment_option_id=option_id,
+ ).json()["pending_token"]
+ monkeypatch.setattr(
+ "app.api.forms.get_checkout",
+ lambda **kw: SumUpCheckout(
+ id="co_1",
+ status="PAID",
+ hosted_checkout_url=None,
+ transaction_code="TEENSK4W2K",
+ transaction_id="tx-uuid-1",
+ ),
+ )
+
+ volunteer_id = _pending_status(raw_client, edition, token)["volunteer_id"]
+
+ fiche = client.get(f"/editions/{edition.id}/volunteers/{volunteer_id}").json()
+ assert fiche["fee_transaction_code"] == "TEENSK4W2K"
+ assert fiche["fee_transaction_id"] == "tx-uuid-1"
+
+
def test_payment_confirmation_sends_an_email_on_promotion(
raw_client, client, edition, monkeypatch, outbox
):
diff --git a/backend/tests/test_request_logging.py b/backend/tests/test_request_logging.py
new file mode 100644
index 0000000..799fa0a
--- /dev/null
+++ b/backend/tests/test_request_logging.py
@@ -0,0 +1,78 @@
+"""Journal des requêtes (`app/request_logging.py`) : référence de requête,
+route modèle sans jeton ni chaîne de requête, erreurs détaillées sans
+valeur saisie."""
+
+import logging
+
+import pytest
+from fastapi.testclient import TestClient
+
+from app.main import app
+
+
+def test_each_response_carries_a_request_id(client):
+ resp = client.get("/health")
+ assert len(resp.headers["X-Request-ID"]) >= 8
+
+
+def test_a_well_formed_incoming_request_id_is_kept(client):
+ resp = client.get("/health", headers={"X-Request-ID": "caddy-1234abcd"})
+ assert resp.headers["X-Request-ID"] == "caddy-1234abcd"
+
+
+def test_a_malformed_incoming_request_id_is_replaced(client):
+ resp = client.get("/health", headers={"X-Request-ID": "x\nforged log line"})
+ assert resp.headers["X-Request-ID"] != "x\nforged log line"
+
+
+def test_access_line_uses_the_route_template_and_hides_tokens(raw_client, edition, caplog):
+ with caplog.at_level(logging.INFO, logger="app.request"):
+ raw_client.get(f"/editions/{edition.id}/form/public?token=secret-access-token")
+
+ line = next(r.getMessage() for r in caplog.records if r.name == "app.request")
+ assert "/editions/{edition_id}/form/public" in line
+ assert f"edition_id={edition.id}" in line
+ assert "secret-access-token" not in caplog.text
+
+
+def test_business_error_code_is_logged(client, edition, caplog):
+ with caplog.at_level(logging.INFO, logger="app.request"):
+ resp = client.get(f"/editions/{edition.id}/volunteers/00000000-0000-0000-0000-000000000000")
+
+ assert resp.status_code == 404
+ record = next(r for r in caplog.records if r.name == "app.request")
+ assert record.levelno == logging.WARNING
+ assert "code=error.not_found" in record.getMessage()
+
+
+def test_validation_error_logs_the_field_but_not_the_value(client, edition, caplog):
+ with caplog.at_level(logging.INFO, logger="app.request"):
+ resp = client.post(
+ f"/editions/{edition.id}/volunteers",
+ json={"first_name": "Alexandra-Secret", "last_name": 12345678},
+ )
+
+ assert resp.status_code == 422
+ assert "last_name" in caplog.text
+ assert "Alexandra-Secret" not in caplog.text
+
+
+@pytest.fixture
+def boom_route():
+ def boom():
+ raise RuntimeError("panne")
+
+ app.add_api_route("/__boom", boom)
+ yield
+ app.router.routes = [r for r in app.router.routes if getattr(r, "path", None) != "/__boom"]
+
+
+def test_unhandled_exception_returns_a_reference(boom_route, caplog):
+ with caplog.at_level(logging.ERROR, logger="app.request"):
+ resp = TestClient(app).get("/__boom")
+
+ assert resp.status_code == 500
+ body = resp.json()
+ assert body["detail"]["code"] == "error.internal"
+ assert body["detail"]["request_id"] == resp.headers["X-Request-ID"]
+ assert "RuntimeError" in caplog.text
diff --git a/backend/tests/test_sumup_client.py b/backend/tests/test_sumup_client.py
new file mode 100644
index 0000000..aa15dac
--- /dev/null
+++ b/backend/tests/test_sumup_client.py
@@ -0,0 +1,59 @@
+"""Client SumUp : lecture de la transaction d'un checkout payé, et
+diagnostic d'un appel refusé (journalisé sans données du payeur)."""
+
+import logging
+
+import httpx
+
+from app.sumup import client as sumup
+
+
+def test_transaction_is_read_from_the_checkout():
+ checkout = sumup._to_checkout(
+ {
+ "id": "co_1",
+ "status": "PAID",
+ "transaction_code": "TEENSK4W2K",
+ "transaction_id": "tx-1",
+ }
+ )
+ assert checkout.transaction_code == "TEENSK4W2K"
+ assert checkout.transaction_id == "tx-1"
+
+
+def test_transaction_falls_back_to_the_successful_attempt():
+ checkout = sumup._to_checkout(
+ {
+ "id": "co_1",
+ "status": "PAID",
+ "transactions": [
+ {"id": "tx-failed", "transaction_code": "TFAILED", "status": "FAILED"},
+ {"id": "tx-ok", "transaction_code": "TOK", "status": "SUCCESSFUL"},
+ ],
+ }
+ )
+ assert checkout.transaction_code == "TOK"
+ assert checkout.transaction_id == "tx-ok"
+
+
+def test_unpaid_checkout_has_no_transaction():
+ checkout = sumup._to_checkout({"id": "co_1", "status": "PENDING"})
+ assert checkout.transaction_code is None
+ assert checkout.transaction_id is None
+
+
+def test_refused_call_logs_the_sumup_error_code(monkeypatch, caplog):
+ request = httpx.Request("POST", "https://api.sumup.com/v0.1/checkouts")
+ response = httpx.Response(
+ 409,
+ json={"error_code": "DUPLICATED_CHECKOUT", "message": "Checkout already exists"},
+ request=request,
+ )
+ monkeypatch.setattr(httpx, "request", lambda *a, **kw: response)
+
+ with caplog.at_level(logging.WARNING, logger="app.sumup"):
+ result = sumup.get_checkout(api_key="k", checkout_id="co_1")
+
+ assert result is None
+ assert "HTTP 409" in caplog.text
+ assert "DUPLICATED_CHECKOUT" in caplog.text
diff --git a/frontend/src/api/http-client.ts b/frontend/src/api/http-client.ts
index 8c92cce..bc93497 100644
--- a/frontend/src/api/http-client.ts
+++ b/frontend/src/api/http-client.ts
@@ -70,7 +70,11 @@ export function createHttpApi(baseUrl: string): PlanningApi {
? ((detail as { violations: [] }).violations ?? [])
: [];
if (res.status === 401 && !path.includes('/auth/')) notifyUnauthorized();
- throw new ApiError(res.status, code, violations);
+ const requestId = res.headers.get('X-Request-ID');
+ if (res.status >= 500) {
+ console.error(`API ${res.status} ${path} — référence ${requestId ?? '?'}`, detail);
+ }
+ throw new ApiError(res.status, code, violations, requestId);
}
return body as T;
}
@@ -99,7 +103,7 @@ export function createHttpApi(baseUrl: string): PlanningApi {
// Corps non JSON (ex. erreur réseau) : garde le code générique.
}
if (res.status === 401) notifyUnauthorized();
- throw new ApiError(res.status, code, []);
+ throw new ApiError(res.status, code, [], res.headers.get('X-Request-ID'));
}
return res.blob();
}
diff --git a/frontend/src/api/mock/dataset.ts b/frontend/src/api/mock/dataset.ts
index b454c1a..d872ff1 100644
--- a/frontend/src/api/mock/dataset.ts
+++ b/frontend/src/api/mock/dataset.ts
@@ -299,6 +299,8 @@ export function buildMockState(): MockState {
mecenat_country: null,
fee_payment_status: null,
fee_paid_at: null,
+ fee_transaction_code: null,
+ fee_transaction_id: null,
created_at: NOW,
updated_at: NOW,
});
@@ -316,6 +318,8 @@ export function buildMockState(): MockState {
fee_amount: mecenat ? 40 : 15,
fee_payment_status: 'PAID',
fee_paid_at: v.created_at,
+ fee_transaction_code: `TDEMO${String(i).padStart(5, '0')}`,
+ fee_transaction_id: `00000000-0000-4000-8000-${String(i).padStart(12, '0')}`,
...(mecenat
? { mecenat_address: '1 rue du Festival', mecenat_city: 'Rennes', mecenat_postal_code: '35000', mecenat_country: 'France' }
: {}),
diff --git a/frontend/src/api/mock/mock-client.ts b/frontend/src/api/mock/mock-client.ts
index e880089..46734d6 100644
--- a/frontend/src/api/mock/mock-client.ts
+++ b/frontend/src/api/mock/mock-client.ts
@@ -244,6 +244,8 @@ function promotePending(pending: MockPendingSubmission): VolunteerRead {
mecenat_country: pending.mecenatCountry,
fee_payment_status: 'PAID',
fee_paid_at: now,
+ fee_transaction_code: `TMOCK${String(state.seq).padStart(5, '0')}`,
+ fee_transaction_id: `mock-transaction-${state.seq}`,
created_at: now,
updated_at: now,
};
@@ -1005,6 +1007,8 @@ export function createMockApi(): PlanningApi {
mecenat_country: null,
fee_payment_status: null,
fee_paid_at: null,
+ fee_transaction_code: null,
+ fee_transaction_id: null,
created_at: now,
updated_at: now,
};
@@ -1310,6 +1314,8 @@ export function createMockApi(): PlanningApi {
mecenat_country: null,
fee_payment_status: null,
fee_paid_at: null,
+ fee_transaction_code: null,
+ fee_transaction_id: null,
created_at: now,
updated_at: now,
};
@@ -1778,6 +1784,8 @@ export function createMockApi(): PlanningApi {
mecenat_country: null,
fee_payment_status: null,
fee_paid_at: null,
+ fee_transaction_code: null,
+ fee_transaction_id: null,
created_at: now,
updated_at: now,
};
diff --git a/frontend/src/api/schema.ts b/frontend/src/api/schema.ts
index 31470b5..3e1b7bd 100644
--- a/frontend/src/api/schema.ts
+++ b/frontend/src/api/schema.ts
@@ -117,6 +117,12 @@ export interface VolunteerRead {
fee_amount: number | null;
fee_payment_status: FeePaymentStatus | null;
fee_paid_at: string | null;
+ /** Transaction SumUp qui a réglé la cotisation : `fee_transaction_code` est
+ * le code affiché dans le tableau de bord SumUp (ex. `TEENSK4W2K`),
+ * `fee_transaction_id` l'identifiant technique. `null` tant que non payée
+ * (ou payée avant que l'appli ne l'enregistre). */
+ fee_transaction_code: string | null;
+ fee_transaction_id: string | null;
/** Coordonnées pour un reçu fiscal (cerfa) — seulement si payé via un type
* de paiement « mécénat ». */
mecenat_address: string | null;
diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts
index 6cea4e6..fdb6b61 100644
--- a/frontend/src/api/types.ts
+++ b/frontend/src/api/types.ts
@@ -233,6 +233,9 @@ export class ApiError extends Error {
public readonly status: number,
public readonly code: string,
public readonly violations: Violation[] = [],
+ /** En-tête `X-Request-ID` de la réponse : référence à communiquer pour
+ * retrouver l'erreur dans les logs du serveur (`app/request_logging.py`). */
+ public readonly requestId: string | null = null,
) {
super(code);
this.name = 'ApiError';
diff --git a/frontend/src/components/state.tsx b/frontend/src/components/state.tsx
index 9a4c0b3..9775136 100644
--- a/frontend/src/components/state.tsx
+++ b/frontend/src/components/state.tsx
@@ -35,6 +35,11 @@ export function ErrorNote({ error }: { error: unknown }) {
{known ? t(code) : code}
+ {error instanceof ApiError && error.requestId ? (
+
+ {t('app.error.reference', { id: error.requestId })}
+
+ ) : null}
);
}
diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json
index 39b71f8..8c376b3 100644
--- a/frontend/src/i18n/locales/fr.json
+++ b/frontend/src/i18n/locales/fr.json
@@ -7,7 +7,9 @@
"app.loading": "Chargement…",
"common.now": "MAINTENANT",
"app.error.generic": "Une erreur est survenue",
+ "app.error.reference": "Référence à communiquer : {id}",
"error.generic": "Erreur inattendue",
+ "error.internal": "Erreur interne du serveur",
"error.not_found": "Ressource introuvable",
"error.forbidden": "Accès refusé",
"error.unauthorized": "Authentification requise",
@@ -307,6 +309,11 @@
"config.vol.fee.mecenat": "Mécénat — reçu fiscal",
"config.vol.fee.onSite": "Paiera sur place",
"config.vol.fee.none": "aucune cotisation déclarée",
+ "config.vol.fee.paidAt": "Payée le",
+ "config.vol.fee.transaction": "Transaction SumUp",
+ "config.vol.fee.transactionHint": "Code de la transaction tel qu'affiché dans le tableau de bord SumUp : recherchez-le là-bas pour retrouver ce paiement. Côté SumUp, la description de la transaction porte aussi le nom du bénévole.",
+ "config.vol.fee.transactionUnknown": "non enregistrée (paiement antérieur à ce suivi)",
+ "config.vol.fee.transactionId": "Identifiant",
"config.vol.fee.onSiteShort": "Sur place",
"config.vol.fee.onlineShort": "En ligne",
"config.vol.fee.mecenatShort": "Mécénat",
diff --git a/frontend/src/pages/config/ConfigVolunteers.tsx b/frontend/src/pages/config/ConfigVolunteers.tsx
index f24de83..bac4dc1 100644
--- a/frontend/src/pages/config/ConfigVolunteers.tsx
+++ b/frontend/src/pages/config/ConfigVolunteers.tsx
@@ -37,7 +37,7 @@ import { ErrorNote, Loading } from '@/components/state';
import { feeMode } from '@/lib/fee';
import { foldAccents } from '@/lib/planning';
import { t } from '@/i18n';
-import { shortDayLabel } from '@/lib/time';
+import { datePart, shortDayLabel, timePart } from '@/lib/time';
import { cx } from '@/lib/cx';
const MAP_FIELDS: Array<{ key: ImportFieldKey; required: boolean }> = [
@@ -273,6 +273,7 @@ function FeeSection({
{volunteer.fee_payment_label} — {volunteer.fee_amount} €
) : null}
+ {volunteer.fee_payment_status === 'PAID' ? : null}
{volunteer.mecenat_address ? (
{t('config.vol.mecenatAddress')} : {volunteer.mecenat_address},{' '}
@@ -298,6 +299,45 @@ function FeeSection({
);
}
+/**
+ * Rapprochement avec SumUp : date du paiement et transaction qui l'a réglé.
+ * Le code (`TEENSK4W2K`) est celui que montre le tableau de bord SumUp —
+ * c'est lui qu'on y recherche pour retrouver le paiement de ce bénévole.
+ * Absent pour un paiement confirmé avant que l'appli ne l'enregistre.
+ */
+function FeeTransaction({ volunteer }: { volunteer: VolunteerRead }) {
+ const paidAt = volunteer.fee_paid_at;
+ return (
+
+ {paidAt ? (
+ <>
+ - {t('config.vol.fee.paidAt')}
+ -
+ {datePart(paidAt).split('-').reverse().join('/')} {timePart(paidAt)}
+
+ >
+ ) : null}
+ -
+ {t('config.vol.fee.transaction')}{' '}
+
+
+ -
+ {volunteer.fee_transaction_code ? (
+ {volunteer.fee_transaction_code}
+ ) : (
+ {t('config.vol.fee.transactionUnknown')}
+ )}
+
+ {volunteer.fee_transaction_id ? (
+ <>
+ - {t('config.vol.fee.transactionId')}
+ - {volunteer.fee_transaction_id}
+ >
+ ) : null}
+
+ );
+}
+
/**
* Postes dont ce bénévole est référent (`Position.lead_account_id`). Cocher
* un poste crée ou met à niveau son accès de connexion en RÉFÉRENT (LEAD) —