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 "
{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 ( +