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) —