Logs de diagnostic et rapprochement SumUp ↔ bénévole
ci-cd / build-backend (push) Successful in 38s
ci-cd / build-frontend (push) Successful in 20s
ci-cd / deploy (push) Successful in 13s

- 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>
This commit is contained in:
AdamThomas
2026-10-12 18:54:35 +02:00
co-authored by Claude Opus 5.5
parent d722a81a87
commit d0fa1e109c
27 changed files with 798 additions and 16 deletions
+33 -4
View File
@@ -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 +
+35
View File
@@ -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)
+113 -3
View File
@@ -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)
+15 -1
View File
@@ -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
+9
View File
@@ -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),
+4
View File
@@ -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
+16
View File
@@ -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(
+5 -1
View File
@@ -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
+4
View File
@@ -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 ""),
+26 -1
View File
@@ -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)
+3
View File
@@ -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)
@@ -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")
+7
View File
@@ -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`) —
+130
View File
@@ -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 "<non routé>"
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)
+70 -3
View File
@@ -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",
+21
View File
@@ -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
+50
View File
@@ -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
):
+78
View File
@@ -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
+59
View File
@@ -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
+6 -2
View File
@@ -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();
}
+4
View File
@@ -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' }
: {}),
+8
View File
@@ -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,
};
+6
View File
@@ -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;
+3
View File
@@ -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';
+5
View File
@@ -35,6 +35,11 @@ export function ErrorNote({ error }: { error: unknown }) {
<span className="mt-1 block font-mono text-[10px] font-normal opacity-80">
{known ? t(code) : code}
</span>
{error instanceof ApiError && error.requestId ? (
<span className="mt-1 block select-all font-mono text-[10px] font-normal opacity-80">
{t('app.error.reference', { id: error.requestId })}
</span>
) : null}
</div>
);
}
+7
View File
@@ -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",
+41 -1
View File
@@ -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} €
</p>
) : null}
{volunteer.fee_payment_status === 'PAID' ? <FeeTransaction volunteer={volunteer} /> : null}
{volunteer.mecenat_address ? (
<p className="font-mono text-[10.5px] leading-relaxed opacity-70">
{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 (
<dl className="grid grid-cols-[auto_1fr] gap-x-[10px] gap-y-[3px] font-mono text-[10.5px]">
{paidAt ? (
<>
<dt className="opacity-70">{t('config.vol.fee.paidAt')}</dt>
<dd>
{datePart(paidAt).split('-').reverse().join('/')} {timePart(paidAt)}
</dd>
</>
) : null}
<dt className="flex items-center gap-[4px] opacity-70">
{t('config.vol.fee.transaction')}{' '}
<HelpTip text={t('config.vol.fee.transactionHint')} />
</dt>
<dd className="min-w-0">
{volunteer.fee_transaction_code ? (
<span className="select-all break-all font-bold">{volunteer.fee_transaction_code}</span>
) : (
<span className="opacity-60">{t('config.vol.fee.transactionUnknown')}</span>
)}
</dd>
{volunteer.fee_transaction_id ? (
<>
<dt className="opacity-70">{t('config.vol.fee.transactionId')}</dt>
<dd className="min-w-0 select-all break-all opacity-70">{volunteer.fee_transaction_id}</dd>
</>
) : null}
</dl>
);
}
/**
* 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) —