diff --git a/CLAUDE.md b/CLAUDE.md index 6663271..e5db669 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -194,6 +194,15 @@ les tables sauf les tables d'association. `POST /editions/{id}/position-sheets/generate` (COORDINATION, `{position_ids?}` — absent = tous) ; un créneau à besoin 0/0 sans aucune affectation n'apparaît pas (rien à imprimer). +- **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 + (`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 → + `` généré côté client, voir `BadgesPage.tsx`). - **`FormTemplate`** : configuration du formulaire public d'inscription d'une édition (une ligne, créée à la demande). `is_open` coupe les nouvelles soumissions sans effacer la configuration. `sumup_merchant_code`/ @@ -203,64 +212,104 @@ les tables sauf les tables d'association. place ; chaîne vide en écriture ⇒ l'efface). - **`FormPaymentOption`** : un type de paiement de la cotisation (ex. « Cotisation classique », « Mécénat ») — `label` + `amount` (montant - fixe), `order`. Liste libre par édition, autant que la coordination veut - (comme les créneaux types `DayPart`), gérée depuis l'écran « Inscriptions » - (bloc « Types de paiement »). CRUD `GET/POST/PATCH/DELETE - /editions/{id}/form/payment-options` (COORDINATION). Unique - (`edition_id`, `label`) — 409 `form.duplicate_payment_option_label` sinon ; - montant `> 0` requis — 422 `form.invalid_sumup_amount` sinon. Le bénévole - en choisit au plus un à l'inscription (voir `Paiement de la cotisation` - plus bas) ; supprimer une option n'affecte jamais les bénévoles qui - l'avaient déjà choisie (copie figée sur `Volunteer`). + fixe), `order`, `requires_mecenat_info` (voir plus bas). Liste libre par + édition, autant que la coordination veut (comme les créneaux types + `DayPart`), gérée depuis l'écran « Inscriptions » (bloc « Types de + paiement »). CRUD `GET/POST/PATCH/DELETE /editions/{id}/form/payment-options` + (COORDINATION). Unique (`edition_id`, `label`) — 409 + `form.duplicate_payment_option_label` sinon ; montant `> 0` requis — 422 + `form.invalid_sumup_amount` sinon. Le bénévole en choisit au plus un à + l'inscription (voir `Paiement de la cotisation` plus bas) ; supprimer une + option n'affecte jamais les bénévoles qui l'avaient déjà choisie (copie + figée sur `Volunteer`). - **`FormField`** : un champ activé sur le formulaire (`field_type` ∈ `EMAIL` / `PHONE` / `POSITION_WISHES` / `ARTIST_WISHES` / `COMPANION_WISH` / `AVAILABLE` / `WILLING_TO_PAY_FEE` / `PHOTO` / `COMMENT`, au plus un par édition — `PUT .../form/fields` remplace tout). **Nom et prénom sont toujours collectés**, indispensables à la création du `Volunteer` : ils ne - sont pas dans cette liste, contrairement aux neuf autres. Deux règles + sont pas dans cette liste, contrairement aux neuf autres. **Un champ activé + est obligatoire** à la soumission (422 `import.row.missing_required_field` + sinon) — `EMAIL`/`PHONE` non vides, `AVAILABLE` coché ; le formulaire + public n'est plus une simple collecte d'infos facultatives. Deux règles propres à ce formulaire (pas à la fiche bénévole classique, qui reste sans limite) : au plus **deux** vœux de poste, et au plus **un** artiste souhaité par jour de festival (`app/api/forms.py:_check_one_artist_wish_per_day`). - `GET/PUT/PATCH /editions/{id}/form[...]` (COORDINATION) configurent ; - `GET /editions/{id}/form/public`, `POST .../form/public/submit` et - `POST .../form/public/sumup-webhook` sont les **trois seuls endpoints de - toute l'API sans authentification** — les deux premiers protégés par - hCaptcha (`app/forms/captcha.py`, `HCAPTCHA_SECRET_KEY` ; vérification - désactivée si absente, comme `EMAIL_ENABLED=false`) et un filet de - sécurité global par édition (`app/auth/rate_limit.py:form_submission_limiter`, - hCaptcha reste la défense principale) ; le webhook est appelé par SumUp - lui-même, donc sans hCaptcha possible — voir plus bas. Le bénévole créé - par une soumission apparaît immédiatement dans la liste de la - coordination, comme n'importe quel autre bénévole. -- **Paiement de la cotisation (SumUp)** : si le bénévole choisit un type de - paiement (`FormPaymentOption`, ci-dessus — le formulaire public - n'affiche ce choix, à la place de la case `WILLING_TO_PAY_FEE` toute - seule, que si `sumup_enabled` sur `PublicFormConfig` est vrai, c.-à-d. - identifiants SumUp renseignés **et** au moins un type de paiement - configuré ; sinon la case reste une simple déclaration, comme avant SumUp) - et que l'édition a configuré ses identifiants, la soumission crée un - checkout hébergé (`app/sumup/client.py:create_checkout`, API SumUp - `POST /v0.1/checkouts`, `hosted_checkout.enabled=true`, montant et - libellé de l'option choisie) et renvoie son URL de paiement dans - `FormSubmissionResult.sumup_checkout_url` (le bénévole y est redirigé - juste après son inscription — pas avant, contrairement à l'ancien lien - Yapla statique). Le libellé et le montant de l'option choisie sont - recopiés sur `Volunteer.fee_payment_label`/`fee_amount` **au moment du - choix** — une copie figée, jamais recalculée si l'option est éditée ou - supprimée ensuite (`FormPaymentOption` reste une pure configuration, - jamais une référence vivante depuis l'historique des bénévoles). - `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`. Best effort, comme l'envoi d'emails : un - échec de l'appel API SumUp (réseau, identifiants invalides…) ne bloque - jamais l'inscription. Le statut est tenu à jour par - `POST .../form/public/sumup-webhook` (`event_type: - CHECKOUT_STATUS_CHANGED`, `id`) : le payload n'étant qu'un identifiant, - l'app relit toujours l'état réel via l'API (`get_checkout`) avant de - l'appliquer, jamais fait confiance au seul contenu du webhook. Visible par - la coordination sur la fiche du bénévole (`ConfigVolunteers.tsx`, libellé - + montant + statut), en lecture seule (pas de bascule manuelle). + `GET/PUT/PATCH /editions/{id}/form[...]` (COORDINATION) configurent ; le + reste des endpoints sous ce routeur est public (**aucune authentification**) : + `GET .../form/public` et `POST .../form/public/submit` (protégés par + hCaptcha, `app/forms/captcha.py`, `HCAPTCHA_SECRET_KEY` — désactivée si + absente, comme `EMAIL_ENABLED=false` — et un filet de sécurité global par + édition, `app/auth/rate_limit.py:form_submission_limiter`), plus, pour le + paiement en ligne (voir plus bas), `POST .../form/public/pending/{token} + /mecenat-info`, `GET .../form/public/pending/{token}`, + `POST .../form/public/pending/{token}/retry-payment` et + `POST .../form/public/sumup-webhook` (appelé par SumUp lui-même, donc sans + hCaptcha possible). Un bénévole créé directement (sans paiement en ligne) + apparaît immédiatement dans la liste de la coordination, comme n'importe + quel autre bénévole. +- **Paiement de la cotisation (SumUp)** — deux chemins selon le choix du + bénévole (le formulaire public n'affiche ce choix, à la place de la case + `WILLING_TO_PAY_FEE` toute seule, que si `sumup_enabled` sur + `PublicFormConfig` est vrai, c.-à-d. identifiants SumUp renseignés **et** + au moins un type de paiement configuré ; sinon la case reste une simple + déclaration à cocher, obligatoire comme les autres, comme avant SumUp) : + - **« Je paierai sur place »** (pas de paiement en ligne) : le `Volunteer` + est créé tout de suite, comme n'importe quelle autre soumission. + - **Un type de paiement en ligne choisi** : **rien n'est compté en base + tant que le paiement n'est pas confirmé** — la soumission crée une + ligne `PendingFormSubmission` (mêmes champs que le formulaire, plus + volatils) à la place du `Volunteer`, pour ne jamais compter en base + quelqu'un qui a rempli le formulaire sans aller au bout du paiement. + Si l'option choisie porte `requires_mecenat_info` (ex. « Mécénat ») : + `FormSubmissionResult.requires_mecenat_info=true` — le front-end + enchaîne sur une étape d'adresse postale + (`POST .../pending/{token}/mecenat-info` : `address`/`city`/ + `postal_code`/`country`, tous obligatoires) avant de créer le checkout, + pour permettre l'édition d'un reçu fiscal (cerfa) plus tard ; ces + coordonnées vivent sur `Volunteer.mecenat_address`/`mecenat_city`/ + `mecenat_postal_code`/`mecenat_country` une fois le bénévole créé. + Sinon, le checkout est créé tout de suite. Dans les deux cas, le + checkout hébergé (`app/sumup/client.py:create_checkout`, API SumUp + `POST /v0.1/checkouts`, `hosted_checkout.enabled=true`, montant et + libellé de l'option choisie, `redirect_url` pointant vers + `/inscription?paiement={pending_id}`) renvoie son URL dans + `FormSubmissionResult.sumup_checkout_url` — le navigateur y est + entièrement redirigé (`window.location.href`, quitte le site). Un + échec de l'appel API SumUp (réseau, identifiants invalides…) ne perd + jamais les informations déjà saisies : `sumup_checkout_url` vaut alors + `None` et le front-end propose de réessayer + (`POST .../pending/{token}/retry-payment`, qui ouvre un nouveau + checkout — un `checkout_reference` n'est réutilisable qu'une fois côté + SumUp). + + Au retour du bénévole sur `/inscription?paiement={token}`, la page de + confirmation interroge en direct `GET .../form/public/pending/{token}` + (toutes les 3 s, jusqu'à 40 fois) : cet endpoint relit toujours l'état + réel du checkout auprès de SumUp (`get_checkout`) à chaque appel — jamais + seulement la dernière valeur connue en base — et **c'est cette confirmation + qui crée enfin le `Volunteer`** (`app/api/forms.py:_promote_pending`), + résolution des vœux de poste/artiste y compris (silencieusement filtrés + s'ils ont entretemps disparu — le paiement a déjà eu lieu, rien ne doit + plus faire échouer la création). Le webhook SumUp + (`POST .../form/public/sumup-webhook`, `event_type: + CHECKOUT_STATUS_CHANGED` + `id`) fait la même chose de son côté, + indépendamment (pas de dépendance stricte à ce que le navigateur reste + ouvert) ; les deux chemins partagent la même fonction d'application du + résultat, idempotente. Le libellé et le montant de l'option choisie sont + recopiés **au moment du choix** sur `Volunteer.fee_payment_label`/ + `fee_amount` — une copie figée, jamais recalculée si l'option est éditée + 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, + 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 + (`_prune_stale_pending`, nettoyage paresseux à chaque soumission/relance, + même principe que `app/auth/rate_limit.py`). Visible par la coordination + sur la fiche du bénévole (`ConfigVolunteers.tsx`, libellé + montant + + statut — jamais l'état de la case à cocher elle-même), en lecture seule + (pas de bascule manuelle). Conventions techniques : @@ -366,6 +415,17 @@ ni corps**). Config : `SMTP_HOST/PORT/USER/PASSWORD/STARTTLS/SSL/FROM`, alias validé. - Diagnostic : `docker compose run --rm backend uv run python -m app.email.check ` — affiche la config effective et la cause exacte d'un échec. +- **Mise en forme** : chaque email part en `multipart/alternative` — texte + brut (source de vérité, utilisé par les tests) **et** une version HTML au + thème de l'appli (`app/email/html.py:render_html` — couleurs/typographie + de `frontend/tailwind.config.js`, mise en page par tableau et styles en + ligne, sans police web ni CSS externe, pour rester lisible dans le plus de + clients mail possible). Construite par `build_message` + (`app/email/sender.py`), pour **tous** les emails envoyés, sans exception. + Logo de l'édition en en-tête si elle en a importé un pour son formulaire + public (`app/email/branding.py:edition_logo_url`, `FormTemplate.logo` — + lien vers `GET .../form/logo`, public) ; à défaut, un bandeau texte avec + le sujet. - **Lien magique** : `POST .../auth/magic-link` envoie l'email contenant `PUBLIC_BASE_URL` + `magic_link_path` + `?token=`. Sans SMTP, le jeton (non @@ -378,6 +438,18 @@ ni corps**). Config : `SMTP_HOST/PORT/USER/PASSWORD/STARTTLS/SSL/FROM`, de l'édition qui ont un email. Envoi en tâche de fond (`BackgroundTasks`, une connexion SMTP réutilisée). Réponse : `{ queued, skipped_no_email, recipients }`. +- **Confirmation d'inscription** (`app/api/forms.py`) : dès qu'un `Volunteer` + est effectivement créé et a un email — que ce soit tout de suite (pas de + paiement en ligne) ou à la promotion d'une `PendingFormSubmission` + (paiement confirmé, voir § 2 `Paiement de la cotisation`) — un email de + confirmation part en tâche de fond (`BackgroundTasks`, best effort, comme + les autres). Deux modèles (`render_registration_confirmation`/ + `render_registration_paid_confirmation`, `app/email/templates.py`) : le + second ajoute le libellé et le montant payés, et tient lieu de **reçu de + paiement** — l'API SumUp Hosted Checkout utilisée ici ne documente pas de + mécanisme pour que SumUp envoie elle-même un reçu au payeur (son + « envoi automatique de reçu » vise le lecteur de carte/point de vente, pas + ce mode d'intégration), donc l'appli ne compte pas dessus. --- @@ -577,8 +649,9 @@ l'étape 21.)* │ │ │ # assignments, solver, matrix, notifications, │ │ │ # badges, position_sheets, forms │ │ ├── auth/ # mot de passe + lien magique + JWT + rôles -│ │ ├── email/ # SMTP (sender) + rendu des emails (templates) +│ │ ├── email/ # SMTP (sender) + rendu texte (templates) et HTML (html.py) + logo (branding.py) │ │ ├── importing/ # import CSV/Excel (parser, text, service) +│ │ ├── exporting/ # export bénévoles en Excel (xlsx.py, openpyxl) │ │ ├── models/ # tables SQLModel │ │ ├── validation/ # moteur pur (models, engine) + loader (base) │ │ ├── solver/ # suggestions OR-Tools (models, engine, loader) @@ -700,7 +773,8 @@ Aucune dépendance locale requise hors Docker. | 22 | Feuilles de poste (PDF, complément des badges) : une page par poste et par jour avec le référent, les créneaux, les bénévoles affectés et leur téléphone — bouton depuis la grille des besoins (COORDINATION) ; photo du bénévole affichée sur son écran et sur l'écran référent (en face du nom de chaque membre de l'équipe) | à valider | | 23 | Créneaux : plus de génération automatique à la création d'un poste — création manuelle à horaire libre (`ConfigGrid.tsx`), génération depuis les créneaux types conservée en option (« Générer les créneaux ») ; grille des besoins redessinée en vrai axe des temps (une colonne = 30 min, bornée à la plage utilisée par jour, cases fusionnées pour chaque créneau — `buildTimeGrid`/`placeInTimeGrid`) | à valider | | 24 | Corrections de stabilité de la grille des besoins (suite à l'étape 23) : positionnement explicite (`gridColumn`/`gridRow`) de chaque cellule (un poste aux créneaux non alignés sur 30 min ne décale plus les postes suivants) ; hiérarchie de `z-index` sans égalité entre les en-têtes figés et isolation de l'infobulle de survol dans son propre contexte d'empilement (ne recouvrait plus la ligne « Affectés / idéal » pendant un défilement) | à valider | -| 25 | Paiement de la cotisation via l'API SumUp (remplace le lien statique Yapla) : plusieurs types de paiement configurables par édition (`FormPaymentOption` — libellé + montant, ex. « Cotisation classique », « Mécénat »), le bénévole en choisit un à l'inscription, checkout hébergé créé à la soumission si l'édition a configuré ses identifiants SumUp, lien de paiement affiché juste après l'inscription, statut (`PENDING`/`PAID`/`FAILED`/`EXPIRED`) tenu à jour par un webhook SumUp et visible (libellé + montant + statut) sur la fiche du bénévole | à valider | +| 25 | Paiement de la cotisation via l'API SumUp (remplace le lien statique Yapla) : plusieurs types de paiement configurables par édition (`FormPaymentOption` — libellé + montant, ex. « Cotisation classique », « Mécénat »), inscription en deux temps pour un paiement en ligne (`PendingFormSubmission` : le `Volunteer` n'est créé qu'à la confirmation du paiement, jamais avant), étape d'adresse postale pour un type « mécénat » (cerfa), page de confirmation en direct au retour de SumUp avec relance possible après échec, tous les champs activés désormais obligatoires, statut (`PENDING`/`PAID`/`FAILED`/`EXPIRED`) tenu à jour par webhook et visible (libellé + montant + statut) sur la fiche du bénévole, email de confirmation d'inscription à la création du bénévole (tenant lieu de reçu quand un paiement est confirmé), mise en forme HTML au thème de l'appli (logo de l'édition en en-tête) pour tous les emails envoyés | à valider | +| 26 | Export Excel de la liste des bénévoles (`GET /editions/{id}/volunteers/export`, COORDINATION, `app/exporting/xlsx.py`) : coordonnées, vœux (résolus en noms lisibles), cotisation et mécénat — bouton dédié sur l'écran de configuration des bénévoles | à 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 a4c493b..554bc22 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -29,7 +29,7 @@ from app.auth.rate_limit import login_limiter, magic_link_limiter from app.auth.tokens import create_access_token, hash_token from app.config import get_settings from app.db import get_session -from app.email import render_magic_link, send +from app.email import edition_logo_url, render_magic_link, send from app.models import Account, MagicLinkToken, local_now router = APIRouter(tags=["auth"]) @@ -143,7 +143,9 @@ def request_magic_link( link=build_magic_link_url(raw), minutes=settings.magic_link_ttl_minutes, ) - background.add_task(send, account.email, subject, body) + background.add_task( + send, account.email, subject, body, logo_url=edition_logo_url(session, edition_id) + ) if not settings.email_enabled: # Aide au dev sans SMTP : le jeton (non nominatif) est journalisé. logger.info( diff --git a/backend/app/api/forms.py b/backend/app/api/forms.py index 4d34381..6167dce 100644 --- a/backend/app/api/forms.py +++ b/backend/app/api/forms.py @@ -20,12 +20,14 @@ libre (`companion_wish_note`) résolu ensuite manuellement par la coordination, faute de savoir si la personne visée est déjà inscrite (voir CLAUDE.md § 2, `Volunteer`).""" +import datetime as dt import json import logging import uuid from fastapi import ( APIRouter, + BackgroundTasks, Depends, File, Form, @@ -38,7 +40,12 @@ from sqlalchemy import delete from sqlalchemy.exc import IntegrityError from sqlmodel import Session, select -from app.api._common import owned_or_404, set_artists, set_wished_positions +from app.api._common import ( + owned_or_404, + resolve_owned_ids, + set_artists, + set_wished_positions, +) from app.api.deps import get_edition from app.api.schemas import ( FormFieldsSet, @@ -48,6 +55,8 @@ from app.api.schemas import ( FormSubmissionResult, FormTemplateRead, FormTemplateUpdate, + MecenatInfoSubmit, + PendingSubmissionStatus, PublicArtistOption, PublicFormConfig, PublicPaymentOption, @@ -58,6 +67,12 @@ from app.auth.deps import require_coordinator from app.auth.rate_limit import form_submission_limiter from app.config import get_settings from app.db import get_session +from app.email import ( + edition_logo_url, + render_registration_confirmation, + render_registration_paid_confirmation, + send, +) from app.forms import verify_hcaptcha from app.models import ( Account, @@ -68,11 +83,12 @@ from app.models import ( FormFieldType, FormPaymentOption, FormTemplate, + PendingFormSubmission, Position, Volunteer, local_now, ) -from app.sumup import create_checkout, get_checkout +from app.sumup import SumUpCheckout, create_checkout, get_checkout logger = logging.getLogger("app.forms") @@ -83,6 +99,11 @@ _ALLOWED_PHOTO_TYPES = {"image/png", "image/jpeg"} # Vœux de poste : au plus deux, pour éviter que tout le monde coche tous les # postes « au cas où » — force une vraie priorisation dès le formulaire. _MAX_POSITION_WISHES = 2 +# Durée de vie d'une inscription en attente de paiement jamais menée à terme +# (ni payée, ni relancée) — purgée au-delà (voir `_prune_stale_pending`), +# pour ne pas garder indéfiniment des coordonnées de bénévoles qui n'ont +# jamais payé. +_PENDING_STALE_HOURS = 48 # --------------------------------------------------------------------------- # @@ -211,6 +232,7 @@ def create_payment_option( label=payload.label.strip(), amount=payload.amount, order=payload.order, + requires_mecenat_info=payload.requires_mecenat_info, ) session.add(option) try: @@ -360,11 +382,79 @@ def get_public_form( description=template.description, sumup_enabled=_sumup_enabled(template, options), payment_options=[ - PublicPaymentOption(id=o.id, label=o.label, amount=o.amount) for o in options + PublicPaymentOption( + id=o.id, + label=o.label, + amount=o.amount, + requires_mecenat_info=o.requires_mecenat_info, + ) + for o in options ], ) +def _prune_stale_pending(session: Session, edition_id: uuid.UUID) -> None: + """Nettoyage paresseux (même principe que `app/auth/rate_limit.py`) : + supprime les inscriptions en attente jamais payées et jamais relancées + depuis `_PENDING_STALE_HOURS` — appelé à chaque soumission/relance, pas + de tâche planifiée dans cette appli.""" + cutoff = local_now() - dt.timedelta(hours=_PENDING_STALE_HOURS) + session.execute( + delete(PendingFormSubmission).where( + PendingFormSubmission.edition_id == edition_id, + PendingFormSubmission.promoted_volunteer_id.is_(None), + PendingFormSubmission.created_at < cutoff, + ) + ) + session.commit() + + +async def _read_photo(photo: UploadFile | None) -> tuple[bytes, str] | None: + """Validation partagée par l'inscription immédiate et l'inscription en + attente de paiement — `None` si aucun fichier fourni.""" + if photo is None or not photo.filename: + return None + content_type = photo.content_type or "" + if content_type not in _ALLOWED_PHOTO_TYPES: + raise HTTPException( + status_code=422, detail={"code": "form.photo_type_not_supported"} + ) + content = await photo.read() + if len(content) > _MAX_PHOTO_BYTES: + raise HTTPException(status_code=413, detail={"code": "form.photo_too_large"}) + return (content, content_type) if content else None + + +def _filter_owned_ids( + session: Session, model, edition_id: uuid.UUID, ids: list[uuid.UUID] +) -> list[uuid.UUID]: + """Comme `resolve_owned_ids` (`app/api/_common.py`) mais silencieux : ne + garde que les ids toujours valides pour cette édition, sans jamais lever. + Utilisé à la promotion d'une inscription en attente — le paiement a déjà + eu lieu, un poste/artiste supprimé entretemps ne doit jamais faire + échouer la création du bénévole (voir `_promote_pending`).""" + wanted = list(dict.fromkeys(ids)) + if not wanted: + return [] + found = set( + session.exec( + select(model.id).where( + model.edition_id == edition_id, model.id.in_(wanted) + ) + ) + ) + return [i for i in wanted if i in found] + + +def _get_pending_or_404( + session: Session, edition_id: uuid.UUID, token: uuid.UUID +) -> PendingFormSubmission: + pending = session.get(PendingFormSubmission, token) + if pending is None or pending.edition_id != edition_id: + raise HTTPException(status_code=404, detail={"code": "error.not_found"}) + return pending + + def _parse_id_list(raw: str | None) -> list[uuid.UUID]: """`position_wish_ids`/`artist_wish_ids` arrivent en JSON dans un champ `multipart/form-data` (impossible d'y envoyer un tableau natif).""" @@ -405,6 +495,7 @@ def _check_one_artist_wish_per_day( status_code=status.HTTP_201_CREATED, ) async def submit_public_form( + background: BackgroundTasks, edition: Edition = Depends(get_edition), session: Session = Depends(get_session), first_name: str = Form(...), @@ -415,7 +506,6 @@ async def submit_public_form( comment: str | None = Form(None), companion_wish_note: str | None = Form(None), available: bool = Form(False), - willing_to_pay_fee: bool = Form(False), payment_option_id: str | None = Form(None), position_wish_ids: str | None = Form(None), artist_wish_ids: str | None = Form(None), @@ -424,6 +514,7 @@ async def submit_public_form( # Filet de sécurité global (par édition) : hCaptcha est la défense # principale, voir `app/auth/rate_limit.py`. form_submission_limiter.hit(str(edition.id)) + _prune_stale_pending(session, edition.id) template = _get_or_create_template(session, edition.id) if not template.is_open: @@ -443,6 +534,26 @@ async def submit_public_form( fields = set(_enabled_fields(session, edition.id)) + # Tout champ activé par la coordination est obligatoire — le formulaire + # public n'est plus une simple collecte d'infos facultatives. + email = (email or "").strip() or None + phone = (phone or "").strip() or None + comment = (comment or "").strip() or None + companion_wish_note = (companion_wish_note or "").strip() or None + for active, field_name, ok in ( + (FormFieldType.EMAIL in fields, "email", bool(email)), + (FormFieldType.PHONE in fields, "phone", bool(phone)), + (FormFieldType.AVAILABLE in fields, "available", available), + ): + if active and not ok: + raise HTTPException( + status_code=422, + detail={ + "code": "import.row.missing_required_field", + "params": {"field": field_name}, + }, + ) + position_ids = ( _parse_id_list(position_wish_ids) if FormFieldType.POSITION_WISHES in fields else [] ) @@ -450,12 +561,15 @@ async def submit_public_form( raise HTTPException( status_code=422, detail={"code": "form.too_many_position_wishes"} ) + if position_ids: + resolve_owned_ids(session, Position, edition.id, position_ids, "position.unknown") artist_ids = ( _parse_id_list(artist_wish_ids) if FormFieldType.ARTIST_WISHES in fields else [] ) if artist_ids: _check_one_artist_wish_per_day(session, edition.id, artist_ids) + resolve_owned_ids(session, Artist, edition.id, artist_ids, "artist.unknown") payment_option: FormPaymentOption | None = None if FormFieldType.WILLING_TO_PAY_FEE in fields and payment_option_id: @@ -471,109 +585,339 @@ async def submit_public_form( status_code=422, detail={"code": "form.unknown_payment_option"} ) - volunteer = Volunteer( + photo_data = await _read_photo(photo) if FormFieldType.PHOTO in fields else None + + if payment_option is None: + # Pas de paiement en ligne (case désactivée, ou « je paierai sur + # place ») : rien à attendre, le bénévole est créé tout de suite. + volunteer = Volunteer( + edition_id=edition.id, + first_name=first_name.strip().title(), + last_name=last_name.strip().title(), + email=email if FormFieldType.EMAIL in fields else None, + phone=phone if FormFieldType.PHONE in fields else None, + comment=comment if FormFieldType.COMMENT in fields else None, + companion_wish_note=( + companion_wish_note if FormFieldType.COMPANION_WISH in fields else None + ), + self_declared_available=( + available if FormFieldType.AVAILABLE in fields else False + ), + willing_to_pay_fee=FormFieldType.WILLING_TO_PAY_FEE in fields, + ) + if photo_data: + volunteer.photo, volunteer.photo_content_type = photo_data + session.add(volunteer) + session.flush() + if FormFieldType.POSITION_WISHES in fields: + set_wished_positions(session, volunteer, position_ids) + if FormFieldType.ARTIST_WISHES in fields: + set_artists(session, volunteer, artist_ids) + session.commit() + session.refresh(volunteer) + if volunteer.email: + subject, body = render_registration_confirmation(first_name=volunteer.first_name) + background.add_task( + send, + volunteer.email, + subject, + body, + logo_url=edition_logo_url(session, edition.id), + ) + return FormSubmissionResult(volunteer_id=volunteer.id) + + # Paiement en ligne choisi : rien n'est compté en base tant que le + # paiement n'est pas confirmé — voir `PendingFormSubmission` et + # `_promote_pending`. + pending = PendingFormSubmission( edition_id=edition.id, first_name=first_name.strip().title(), last_name=last_name.strip().title(), - email=((email or "").strip() or None) if FormFieldType.EMAIL in fields else None, - phone=((phone or "").strip() or None) if FormFieldType.PHONE in fields else None, - comment=( - (comment or "").strip() or None if FormFieldType.COMMENT in fields else None - ), + email=email if FormFieldType.EMAIL in fields else None, + phone=phone if FormFieldType.PHONE in fields else None, + comment=comment if FormFieldType.COMMENT in fields else None, companion_wish_note=( - (companion_wish_note or "").strip() or None - if FormFieldType.COMPANION_WISH in fields - else None + companion_wish_note if FormFieldType.COMPANION_WISH in fields else None ), - self_declared_available=( - available if FormFieldType.AVAILABLE in fields else False + available=available if FormFieldType.AVAILABLE in fields else False, + position_wish_ids_json=( + json.dumps([str(i) for i in position_ids]) if position_ids else None ), - # Choisir un type de paiement vaut « je participe » même sans cocher - # la case (le formulaire public n'affiche d'ailleurs plus qu'un choix - # unique dès qu'au moins un type est configuré, voir PublicSignupPage). - willing_to_pay_fee=( - (willing_to_pay_fee or payment_option is not None) - if FormFieldType.WILLING_TO_PAY_FEE in fields - else False + artist_wish_ids_json=( + json.dumps([str(i) for i in artist_ids]) if artist_ids else None ), + payment_label=payment_option.label, + payment_amount=payment_option.amount, + requires_mecenat_info=payment_option.requires_mecenat_info, ) + if photo_data: + pending.photo, pending.photo_content_type = photo_data + session.add(pending) + session.commit() + session.refresh(pending) - if FormFieldType.PHOTO in fields and photo is not None and photo.filename: - content_type = photo.content_type or "" - if content_type not in _ALLOWED_PHOTO_TYPES: - raise HTTPException( - status_code=422, detail={"code": "form.photo_type_not_supported"} - ) - content = await photo.read() - if len(content) > _MAX_PHOTO_BYTES: - raise HTTPException( - status_code=413, detail={"code": "form.photo_too_large"} - ) - if content: - volunteer.photo = content - volunteer.photo_content_type = content_type + if pending.requires_mecenat_info: + return FormSubmissionResult(pending_token=pending.id, requires_mecenat_info=True) + checkout_url = _create_pending_checkout(session, edition, template, pending) + return FormSubmissionResult(pending_token=pending.id, sumup_checkout_url=checkout_url) + + +def _create_pending_checkout( + session: Session, edition: Edition, template: FormTemplate, pending: PendingFormSubmission +) -> str | None: + """Crée (ou recrée, voir `retry_pending_payment`) le checkout SumUp d'une + inscription en attente — best effort (comme l'envoi d'emails) : un échec + de l'appel API laisse l'inscription en attente, jamais perdue (la page de + confirmation propose alors de réessayer). `checkout_reference` porte un + suffixe aléatoire à chaque appel : une relance après échec/expiration doit + ouvrir un nouveau checkout SumUp, jamais réutiliser une référence déjà + consommée (409 côté SumUp sinon).""" + base = get_settings().public_base_url.rstrip("/") + checkout = create_checkout( + api_key=template.sumup_api_key, + merchant_code=template.sumup_merchant_code, + amount=pending.payment_amount, + currency="EUR", + checkout_reference=f"{pending.id}-{uuid.uuid4().hex[:8]}", + description=f"{pending.payment_label} — édition {edition.year}", + return_url=f"{base}/api/editions/{edition.id}/form/public/sumup-webhook", + redirect_url=f"{base}/inscription?paiement={pending.id}", + ) + if checkout is None: + return None + pending.checkout_id = checkout.id + pending.checkout_status = FeePaymentStatus.PENDING + session.add(pending) + session.commit() + return checkout.hosted_checkout_url + + +def _promote_pending( + session: Session, + edition: Edition, + pending: PendingFormSubmission, + checkout: SumUpCheckout, + background: BackgroundTasks, +) -> Volunteer: + """Paiement confirmé : crée enfin le `Volunteer` (voir le docstring de + `PendingFormSubmission`), résout les vœux en filtrant silencieusement les + ids devenus invalides (`_filter_owned_ids` — le paiement a déjà eu lieu, + rien ne doit plus faire échouer la création), puis vide les champs + nominatifs de la ligne en attente (RGPD : cette information ne doit plus + vivre qu'à un seul endroit).""" + volunteer = Volunteer( + edition_id=edition.id, + first_name=pending.first_name or "", + last_name=pending.last_name or "", + email=pending.email, + phone=pending.phone, + comment=pending.comment, + companion_wish_note=pending.companion_wish_note, + self_declared_available=pending.available, + willing_to_pay_fee=True, + fee_payment_label=pending.payment_label, + fee_amount=pending.payment_amount, + fee_checkout_id=checkout.id, + fee_payment_status=FeePaymentStatus.PAID, + fee_paid_at=local_now(), + mecenat_address=pending.mecenat_address, + mecenat_city=pending.mecenat_city, + mecenat_postal_code=pending.mecenat_postal_code, + mecenat_country=pending.mecenat_country, + photo=pending.photo, + photo_content_type=pending.photo_content_type, + ) session.add(volunteer) session.flush() - if FormFieldType.POSITION_WISHES in fields: + position_ids = _filter_owned_ids( + session, Position, edition.id, _parse_id_list(pending.position_wish_ids_json) + ) + if position_ids: set_wished_positions(session, volunteer, position_ids) - if FormFieldType.ARTIST_WISHES in fields: + artist_ids = _filter_owned_ids( + session, Artist, edition.id, _parse_id_list(pending.artist_wish_ids_json) + ) + if artist_ids: set_artists(session, volunteer, artist_ids) session.commit() session.refresh(volunteer) - checkout_url = None - if ( - payment_option is not None - and template.sumup_merchant_code - and template.sumup_api_key - ): - checkout_url = _create_fee_checkout( - session, edition, template, volunteer, payment_option + pending.promoted_volunteer_id = volunteer.id + pending.checkout_status = FeePaymentStatus.PAID + pending.first_name = None + pending.last_name = None + pending.email = None + pending.phone = None + pending.comment = None + pending.companion_wish_note = None + pending.photo = None + pending.photo_content_type = None + pending.mecenat_address = None + pending.mecenat_city = None + pending.mecenat_postal_code = None + pending.mecenat_country = None + session.add(pending) + session.commit() + + if volunteer.email: + subject, body = render_registration_paid_confirmation( + first_name=volunteer.first_name, + label=volunteer.fee_payment_label or "", + amount=volunteer.fee_amount or 0, ) - - return FormSubmissionResult(volunteer_id=volunteer.id, sumup_checkout_url=checkout_url) + background.add_task( + send, + volunteer.email, + subject, + body, + logo_url=edition_logo_url(session, edition.id), + ) + return volunteer -def _create_fee_checkout( +def _apply_pending_checkout_result( session: Session, edition: Edition, - template: FormTemplate, - volunteer: Volunteer, - option: FormPaymentOption, -) -> str | None: - """Crée le checkout SumUp du type de paiement choisi — best effort (comme - l'envoi d'emails) : un échec de l'appel API ne remet jamais en cause - l'inscription déjà enregistrée. `option.label`/`option.amount` sont - recopiés sur le bénévole (`fee_payment_label`/`fee_amount`) avant même - l'appel, pour garder une trace du choix même si l'appel échoue.""" - volunteer.fee_payment_label = option.label - volunteer.fee_amount = option.amount + pending: PendingFormSubmission, + checkout: SumUpCheckout, + background: BackgroundTasks, +) -> None: + """Met à jour une inscription en attente à partir d'un état de checkout + fraîchement relu chez SumUp — partagé par le webhook et la vérification + en direct (`get_pending_status`).""" + if pending.promoted_volunteer_id is not None: + return + try: + new_status = FeePaymentStatus(checkout.status) + except ValueError: + return + if new_status == FeePaymentStatus.PAID: + _promote_pending(session, edition, pending, checkout, background) + else: + pending.checkout_status = new_status + session.add(pending) + session.commit() - base = get_settings().public_base_url.rstrip("/") - checkout = create_checkout( - api_key=template.sumup_api_key, - merchant_code=template.sumup_merchant_code, - amount=option.amount, - currency="EUR", - checkout_reference=str(volunteer.id), - description=f"{option.label} — édition {edition.year}", - return_url=f"{base}/api/editions/{edition.id}/form/public/sumup-webhook", - redirect_url=f"{base}/inscription?paiement=ok", - ) - if checkout is not None: - volunteer.fee_checkout_id = checkout.id - volunteer.fee_payment_status = FeePaymentStatus.PENDING - session.add(volunteer) + +@router.post( + "/public/pending/{token}/mecenat-info", + response_model=FormSubmissionResult, +) +def submit_mecenat_info( + token: uuid.UUID, + payload: MecenatInfoSubmit, + edition: Edition = Depends(get_edition), + session: Session = Depends(get_session), +) -> FormSubmissionResult: + """Étape d'adresse postale d'un type de paiement « mécénat », entre la + soumission du formulaire principal et le paiement lui-même — crée le + checkout SumUp une fois ces coordonnées enregistrées.""" + form_submission_limiter.hit(str(edition.id)) + pending = _get_pending_or_404(session, edition.id, token) + if pending.promoted_volunteer_id is not None: + raise HTTPException( + status_code=422, detail={"code": "form.pending_already_paid"} + ) + if not pending.requires_mecenat_info: + raise HTTPException( + status_code=422, detail={"code": "form.pending_not_awaiting_info"} + ) + + address = payload.address.strip() + city = payload.city.strip() + postal_code = payload.postal_code.strip() + country = payload.country.strip() + if not (address and city and postal_code and country): + raise HTTPException( + status_code=422, + detail={ + "code": "import.row.missing_required_field", + "params": {"field": "address"}, + }, + ) + pending.mecenat_address = address + pending.mecenat_city = city + pending.mecenat_postal_code = postal_code + pending.mecenat_country = country + session.add(pending) session.commit() - return checkout.hosted_checkout_url if checkout is not None else None + + template = _get_or_create_template(session, edition.id) + checkout_url = _create_pending_checkout(session, edition, template, pending) + return FormSubmissionResult(pending_token=pending.id, sumup_checkout_url=checkout_url) + + +@router.get("/public/pending/{token}", response_model=PendingSubmissionStatus) +def get_pending_status( + token: uuid.UUID, + background: BackgroundTasks, + edition: Edition = Depends(get_edition), + session: Session = Depends(get_session), +) -> PendingSubmissionStatus: + """Interrogé en direct par la page de confirmation (voir + `PublicSignupPage.tsx`) jusqu'à ce que le statut ne soit plus `PENDING` — + relit toujours l'état réel auprès de SumUp à chaque appel, jamais + seulement la dernière valeur connue en base.""" + pending = _get_pending_or_404(session, edition.id, token) + if pending.promoted_volunteer_id is None and pending.checkout_id: + template = _get_or_create_template(session, edition.id) + if template.sumup_api_key: + checkout = get_checkout( + api_key=template.sumup_api_key, checkout_id=pending.checkout_id + ) + if checkout is not None: + _apply_pending_checkout_result(session, edition, pending, checkout, background) + session.refresh(pending) + + if pending.promoted_volunteer_id is not None: + return PendingSubmissionStatus( + status=FeePaymentStatus.PAID, + label=pending.payment_label, + amount=pending.payment_amount, + volunteer_id=pending.promoted_volunteer_id, + ) + return PendingSubmissionStatus( + status=pending.checkout_status or FeePaymentStatus.PENDING, + label=pending.payment_label, + amount=pending.payment_amount, + ) + + +@router.post( + "/public/pending/{token}/retry-payment", + response_model=FormSubmissionResult, +) +def retry_pending_payment( + token: uuid.UUID, + edition: Edition = Depends(get_edition), + session: Session = Depends(get_session), +) -> FormSubmissionResult: + """Relance un paiement après échec/expiration, sans faire ressaisir le + formulaire — un nouveau checkout SumUp est créé pour la même inscription + en attente (voir `_create_pending_checkout`).""" + form_submission_limiter.hit(str(edition.id)) + pending = _get_pending_or_404(session, edition.id, token) + if pending.promoted_volunteer_id is not None: + raise HTTPException( + status_code=422, detail={"code": "form.pending_already_paid"} + ) + if pending.requires_mecenat_info and pending.mecenat_address is None: + raise HTTPException( + status_code=422, detail={"code": "form.pending_not_awaiting_info"} + ) + + template = _get_or_create_template(session, edition.id) + checkout_url = _create_pending_checkout(session, edition, template, pending) + return FormSubmissionResult(pending_token=pending.id, sumup_checkout_url=checkout_url) @router.post("/public/sumup-webhook", status_code=status.HTTP_200_OK) def sumup_webhook( payload: SumUpWebhookPayload, + background: BackgroundTasks, edition: Edition = Depends(get_edition), session: Session = Depends(get_session), ) -> Response: @@ -583,29 +927,40 @@ def sumup_webhook( (voir `app/sumup/client.py:get_checkout`). Répond toujours `200`, y compris sur un événement inconnu — SumUp réessaie sinon inutilement.""" template = _get_or_create_template(session, edition.id) - if template.sumup_api_key: - checkout = get_checkout(api_key=template.sumup_api_key, checkout_id=payload.id) - volunteer = ( - session.exec( - select(Volunteer).where( - Volunteer.edition_id == edition.id, - Volunteer.fee_checkout_id == payload.id, - ) - ).first() - if checkout is not None - else None + if not template.sumup_api_key: + 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.") + return Response(status_code=status.HTTP_200_OK) + + pending = session.exec( + select(PendingFormSubmission).where( + PendingFormSubmission.edition_id == edition.id, + PendingFormSubmission.checkout_id == payload.id, ) - if checkout is not None and volunteer is not None: - try: - new_status = FeePaymentStatus(checkout.status) - except ValueError: - new_status = None - if new_status is not None: - volunteer.fee_payment_status = new_status - if new_status == FeePaymentStatus.PAID and volunteer.fee_paid_at is None: - volunteer.fee_paid_at = local_now() - session.add(volunteer) - session.commit() - elif checkout is None: - logger.warning("Webhook SumUp : relecture du checkout impossible.") + ).first() + if pending is not None: + _apply_pending_checkout_result(session, edition, pending, checkout, background) + return Response(status_code=status.HTTP_200_OK) + + # Filet de sécurité pour d'anciennes inscriptions créées avant le passage + # à l'inscription en deux temps (voir CLAUDE.md § 2, Paiement de la + # cotisation) : un `Volunteer` directement lié à un checkout. + volunteer = session.exec( + select(Volunteer).where( + Volunteer.edition_id == edition.id, Volunteer.fee_checkout_id == payload.id + ) + ).first() + if volunteer is not None: + try: + new_status = FeePaymentStatus(checkout.status) + except ValueError: + return Response(status_code=status.HTTP_200_OK) + volunteer.fee_payment_status = new_status + if new_status == FeePaymentStatus.PAID and volunteer.fee_paid_at is None: + volunteer.fee_paid_at = local_now() + session.add(volunteer) + session.commit() return Response(status_code=status.HTTP_200_OK) diff --git a/backend/app/api/notifications.py b/backend/app/api/notifications.py index b6251e3..e844f88 100644 --- a/backend/app/api/notifications.py +++ b/backend/app/api/notifications.py @@ -14,7 +14,7 @@ from app.api.schemas import ( from app.auth.deps import require_coordinator from app.auth.magic_link import build_magic_link_url, issue_magic_link_token from app.db import get_session -from app.email import render_planning, send_many +from app.email import edition_logo_url, render_planning, send_many from app.i18n import t from app.models import Account, Assignment, Edition, Position, Shift, Volunteer @@ -112,7 +112,7 @@ def send_planning_notification( recipients.append(volunteer.id) session.commit() # persiste les éventuels MagicLinkToken émis - background.add_task(send_many, items) + background.add_task(send_many, items, logo_url=edition_logo_url(session, edition.id)) return PlanningNotificationResult( queued=len(items), skipped_no_email=skipped_no_email, diff --git a/backend/app/api/schemas.py b/backend/app/api/schemas.py index c01c6f7..d610e24 100644 --- a/backend/app/api/schemas.py +++ b/backend/app/api/schemas.py @@ -102,6 +102,12 @@ class VolunteerRead(ApiModel): fee_amount: float | None fee_payment_status: FeePaymentStatus | None fee_paid_at: dt.datetime | 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 + mecenat_city: str | None + mecenat_postal_code: str | None + mecenat_country: str | None created_at: dt.datetime updated_at: dt.datetime @@ -526,6 +532,7 @@ class FormPaymentOptionRead(ApiModel): label: str amount: float order: int + requires_mecenat_info: bool created_at: dt.datetime updated_at: dt.datetime @@ -534,12 +541,14 @@ class FormPaymentOptionCreate(ApiModel): label: str amount: float order: int = 0 + requires_mecenat_info: bool = False class FormPaymentOptionUpdate(ApiModel): label: str | None = None amount: float | None = None order: int | None = None + requires_mecenat_info: bool | None = None class PublicPositionOption(ApiModel): @@ -560,6 +569,7 @@ class PublicPaymentOption(ApiModel): id: uuid.UUID label: str amount: float + requires_mecenat_info: bool class PublicFormConfig(ApiModel): @@ -582,14 +592,46 @@ class PublicFormConfig(ApiModel): class FormSubmissionResult(ApiModel): - volunteer_id: uuid.UUID - # Lien de paiement hébergé SumUp, si un checkout a pu être créé (voir - # `app/api/forms.py:submit_public_form`) — `None` si le bénévole n'a - # choisi aucun type de paiement, si SumUp n'est pas configuré, ou en cas - # d'échec de l'appel API (best effort, l'inscription reste valide). + """Trois formes possibles, selon le choix de paiement (voir + `app/api/forms.py:submit_public_form`) : + - aucun paiement en ligne choisi (case désactivée, ou « je paierai sur + place ») : `volunteer_id` seul, le bénévole est déjà en base ; + - paiement en ligne, type « mécénat » : `pending_token` + + `requires_mecenat_info=True`, rien en base encore — direction l'étape + d'adresse (`submit_mecenat_info`) ; + - paiement en ligne, type simple : `pending_token` + `sumup_checkout_url`, + direction la page de paiement SumUp. Toujours rien en base tant que le + paiement n'est pas confirmé (voir `PendingFormSubmission`).""" + + volunteer_id: uuid.UUID | None = None + pending_token: uuid.UUID | None = None + requires_mecenat_info: bool = False sumup_checkout_url: str | None = None +class MecenatInfoSubmit(ApiModel): + """Étape d'adresse postale pour un type de paiement « mécénat » + (`POST .../form/public/pending/{token}/mecenat-info`) — tous obligatoires, + nécessaires à l'édition d'un reçu fiscal (cerfa).""" + + address: str + city: str + postal_code: str + country: str + + +class PendingSubmissionStatus(ApiModel): + """Réponse de `GET .../form/public/pending/{token}` — interrogée en + direct par la page de confirmation jusqu'à ce que le statut ne soit plus + `PENDING`. Relit toujours l'état réel auprès de SumUp (jamais seulement + la dernière valeur connue), comme le webhook.""" + + status: FeePaymentStatus + label: str + amount: float + volunteer_id: uuid.UUID | None = None + + class SumUpWebhookPayload(ApiModel): """Corps envoyé par SumUp à `return_url` — voir la doc SumUp sur les webhooks. Le seul événement existant à ce jour.""" diff --git a/backend/app/api/volunteers.py b/backend/app/api/volunteers.py index 9081e7c..e59763b 100644 --- a/backend/app/api/volunteers.py +++ b/backend/app/api/volunteers.py @@ -29,6 +29,7 @@ from app.auth.deps import ( require_staff, ) from app.db import get_session +from app.exporting import generate_volunteers_xlsx from app.models import ( Account, Artist, @@ -169,6 +170,56 @@ def list_volunteers( return [_read(session, v) for v in volunteers] +@router.get("/export") +def export_volunteers( + edition: Edition = Depends(get_edition), + _guard: Account = Depends(require_coordinator), + session: Session = Depends(get_session), +) -> Response: + """Export Excel de la liste des bénévoles — vœux (postes, artistes, + compagnons) résolus en noms lisibles, pas en UUID. Déclaré avant + `/{volunteer_id}` : un chemin statique doit être enregistré avant le + chemin dynamique qui le suivrait sinon FastAPI essaierait de parser + « export » comme un UUID.""" + volunteers_orm = list( + session.exec( + select(Volunteer) + .where(Volunteer.edition_id == edition.id) + .order_by(Volunteer.last_name, Volunteer.first_name) + ) + ) + volunteers = [_read(session, v) for v in volunteers_orm] + position_names = { + p.id: p.name + for p in session.exec( + select(Position).where(Position.edition_id == edition.id) + ) + } + artist_names = { + a.id: a.name + for a in session.exec(select(Artist).where(Artist.edition_id == edition.id)) + } + volunteer_names = { + v.id: f"{v.first_name} {v.last_name}" for v in volunteers_orm + } + + content = generate_volunteers_xlsx( + volunteers, + position_names=position_names, + artist_names=artist_names, + volunteer_names=volunteer_names, + ) + return Response( + content=content, + media_type=( + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + ), + headers={ + "Content-Disposition": f'attachment; filename="benevoles-{edition.year}.xlsx"' + }, + ) + + @router.get("/{volunteer_id}", response_model=VolunteerRead) def get_volunteer( volunteer_id: uuid.UUID, diff --git a/backend/app/email/__init__.py b/backend/app/email/__init__.py index 26bd469..73a4e67 100644 --- a/backend/app/email/__init__.py +++ b/backend/app/email/__init__.py @@ -4,7 +4,21 @@ (sans destinataire ni corps — RGPD). Voir CLAUDE.md. """ +from app.email.branding import edition_logo_url from app.email.sender import send, send_many -from app.email.templates import render_magic_link, render_planning +from app.email.templates import ( + render_magic_link, + render_planning, + render_registration_confirmation, + render_registration_paid_confirmation, +) -__all__ = ["render_magic_link", "render_planning", "send", "send_many"] +__all__ = [ + "edition_logo_url", + "render_magic_link", + "render_planning", + "render_registration_confirmation", + "render_registration_paid_confirmation", + "send", + "send_many", +] diff --git a/backend/app/email/branding.py b/backend/app/email/branding.py new file mode 100644 index 0000000..a96fc78 --- /dev/null +++ b/backend/app/email/branding.py @@ -0,0 +1,20 @@ +"""URL publique du logo d'une édition, pour l'en-tête des emails (voir +`app/email/html.py`) — `None` tant qu'aucun logo n'a été importé sur le +formulaire public (`FormTemplate.logo`, `app/api/forms.py:set_logo`).""" + +import uuid + +from sqlmodel import Session, select + +from app.config import get_settings +from app.models import FormTemplate + + +def edition_logo_url(session: Session, edition_id: uuid.UUID) -> str | None: + template = session.exec( + select(FormTemplate).where(FormTemplate.edition_id == edition_id) + ).first() + if template is None or template.logo is None: + return None + base = get_settings().public_base_url.rstrip("/") + return f"{base}/api/editions/{edition_id}/form/logo" diff --git a/backend/app/email/html.py b/backend/app/email/html.py new file mode 100644 index 0000000..bd162cb --- /dev/null +++ b/backend/app/email/html.py @@ -0,0 +1,76 @@ +"""Version HTML des emails — même thème que l'appli (couleurs/typographie de +`frontend/tailwind.config.js`), logo de l'édition en en-tête si défini. + +Chaque email reste envoyé en `multipart/alternative` (texte + HTML, voir +`app/email/sender.py:build_message`) : le texte brut reste la source de +vérité (accessibilité, clients sans HTML, RGPD — rien de plus dans le HTML +que dans le texte déjà journalisable). Pas de CSS externe ni de police web +(rarement chargées par les clients mail) : mise en page par tableau, styles +en ligne, polices système en repli du style « Archivo »/« DM Mono ».""" + +import html as _html + +_INK = "#14110E" +_CREAM = "#F5EFE1" +_PAPER = "#FFFDF7" +_YELLOW = "#FFD23F" + +_FONT_DISPLAY = "Arial, Helvetica, sans-serif" +_FONT_MONO = "'Courier New', Courier, monospace" + + +def _paragraphs(body: str) -> str: + """`\n\n` sépare les paragraphes, un simple `\n` reste un retour à la + ligne — même convention que les modèles texte (`app/email/templates.py`).""" + blocks = body.strip().split("\n\n") + return "".join( + f'

{_html.escape(block).replace(chr(10), "
")}

' + for block in blocks + if block.strip() + ) + + +def render_html(*, subject: str, body: str, logo_url: str | None) -> str: + """`logo_url` : voir `app/email/branding.py:edition_logo_url` — `None` + tant que l'édition n'a pas importé de logo, l'en-tête retombe alors sur + un simple bandeau texte (le sujet, en capitales).""" + if logo_url: + header = ( + f'' + ) + else: + header = ( + f'{_html.escape(subject)}' + ) + return f""" + + + + + + + +
+ + + + + + + +
+ {header} +
+ {_paragraphs(body)} +
+
+ +""" diff --git a/backend/app/email/sender.py b/backend/app/email/sender.py index 3547751..7a97504 100644 --- a/backend/app/email/sender.py +++ b/backend/app/email/sender.py @@ -12,6 +12,7 @@ from collections.abc import Iterable from email.message import EmailMessage from app.config import Settings, get_settings +from app.email.html import render_html logger = logging.getLogger("app.email") @@ -32,12 +33,21 @@ def _smtp_error_summary(exc: smtplib.SMTPException) -> str: return exc.__class__.__name__ -def build_message(to: str, subject: str, body: str) -> EmailMessage: +def build_message( + to: str, subject: str, body: str, *, logo_url: str | None = None +) -> EmailMessage: + """`logo_url` (voir `app/email/branding.py:edition_logo_url`) : ajoute une + version HTML au thème de l'appli (`app/email/html.py`) en alternative au + texte brut — le texte reste la version de référence, envoyée telle + quelle si aucun client ne préfère le HTML.""" message = EmailMessage() message["From"] = get_settings().smtp_from message["To"] = to message["Subject"] = subject message.set_content(body) + message.add_alternative( + render_html(subject=subject, body=body, logo_url=logo_url), subtype="html" + ) return message @@ -111,10 +121,13 @@ def deliver_many(messages: list[EmailMessage]) -> None: ) -def send(to: str, subject: str, body: str) -> None: - deliver_many([build_message(to, subject, body)]) +def send(to: str, subject: str, body: str, *, logo_url: str | None = None) -> None: + deliver_many([build_message(to, subject, body, logo_url=logo_url)]) -def send_many(items: Iterable[tuple[str, str, str]]) -> None: - """`items` = itérable de `(destinataire, sujet, corps)`.""" - deliver_many([build_message(*item) for item in items]) +def send_many( + items: Iterable[tuple[str, str, str]], *, logo_url: str | None = None +) -> None: + """`items` = itérable de `(destinataire, sujet, corps)` — même logo pour + tous (un seul appel par édition, voir `app/api/notifications.py`).""" + deliver_many([build_message(*item, logo_url=logo_url) for item in items]) diff --git a/backend/app/email/templates.py b/backend/app/email/templates.py index f770583..70a1779 100644 --- a/backend/app/email/templates.py +++ b/backend/app/email/templates.py @@ -21,6 +21,35 @@ def render_magic_link(*, link: str, minutes: int) -> tuple[str, str]: ) +def render_registration_confirmation(*, first_name: str) -> tuple[str, str]: + """Inscription prise en compte, sans paiement en ligne (case désactivée, + ou « je paierai sur place ») — voir `render_registration_paid_confirmation` + pour le cas d'un paiement confirmé.""" + return ( + t("email.registration_confirmation.subject"), + t("email.registration_confirmation.body", prenom=first_name), + ) + + +def render_registration_paid_confirmation( + *, first_name: str, label: str, amount: float +) -> tuple[str, str]: + """Inscription prise en compte **et** paiement en ligne confirmé — envoyé + une fois seulement, au moment de la promotion (`app/api/forms.py: + _promote_pending`). Sert de reçu (montant, type de paiement) en + l'absence de confirmation documentée que l'API SumUp Hosted Checkout en + envoie un elle-même.""" + return ( + t("email.registration_confirmation_paid.subject"), + t( + "email.registration_confirmation_paid.body", + prenom=first_name, + label=label, + montant=amount, + ), + ) + + def _fill(template: str, values: dict[str, object]) -> str: out = template for key in _PLANNING_PLACEHOLDERS: diff --git a/backend/app/exporting/__init__.py b/backend/app/exporting/__init__.py new file mode 100644 index 0000000..baf45ff --- /dev/null +++ b/backend/app/exporting/__init__.py @@ -0,0 +1,3 @@ +from app.exporting.xlsx import generate_volunteers_xlsx + +__all__ = ["generate_volunteers_xlsx"] diff --git a/backend/app/exporting/xlsx.py b/backend/app/exporting/xlsx.py new file mode 100644 index 0000000..91e07b5 --- /dev/null +++ b/backend/app/exporting/xlsx.py @@ -0,0 +1,112 @@ +"""Export Excel de la liste des bénévoles d'une édition — voir +`app/api/volunteers.py:export_volunteers`. Même bibliothèque que l'import +(`openpyxl`, `app/importing/parser.py`), en écriture cette fois. + +Texte en dur (en-têtes de colonnes, libellés) : comme `app/sheets/pdf.py` et +`app/badges/pdf.py`, un document généré n'est pas un texte d'interface — +pas de `app.i18n.t()` ici, contrairement aux réponses de l'API.""" + +import io +import uuid + +from openpyxl import Workbook +from openpyxl.styles import Font + +from app.api.schemas import VolunteerRead + +_HEADER = [ + "Prénom", + "Nom", + "Email", + "Téléphone", + "Disponible", + "Postes souhaités", + "Artistes souhaités", + "Compagnons souhaités", + "Note de compagnon", + "Cotisation choisie", + "Montant (€)", + "Statut du paiement", + "Payée le", + "Mécénat — adresse", + "Mécénat — ville", + "Mécénat — code postal", + "Mécénat — pays", + "Commentaire", + "Inscrit le", +] + +_FEE_STATUS_LABELS = { + "PENDING": "En attente", + "PAID": "Payée", + "FAILED": "Échouée", + "EXPIRED": "Expirée", +} + + +def _yes_no(value: bool) -> str: + return "Oui" if value else "Non" + + +def _join_names(ids: list[uuid.UUID], names: dict[uuid.UUID, str]) -> str: + return ", ".join(names[i] for i in ids if i in names) + + +def _fmt_dt(value) -> str: + return value.strftime("%d/%m/%Y %H:%M") if value else "" + + +def generate_volunteers_xlsx( + volunteers: list[VolunteerRead], + *, + position_names: dict[uuid.UUID, str], + artist_names: dict[uuid.UUID, str], + volunteer_names: dict[uuid.UUID, str], +) -> bytes: + """`position_names`/`artist_names`/`volunteer_names` : cartes id → nom + construites une fois par l'appelant (pas de requête par bénévole) — + voir `app/api/volunteers.py:export_volunteers`.""" + workbook = Workbook() + sheet = workbook.active + sheet.title = "Bénévoles" + sheet.append(_HEADER) + for cell in sheet[1]: + cell.font = Font(bold=True) + + for v in volunteers: + sheet.append( + [ + v.first_name, + v.last_name, + v.email or "", + v.phone or "", + _yes_no(v.self_declared_available), + _join_names(v.wished_position_ids, position_names), + _join_names(v.artist_ids, artist_names), + _join_names(v.wished_companion_ids, volunteer_names), + v.companion_wish_note or "", + v.fee_payment_label or "", + 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), + v.mecenat_address or "", + v.mecenat_city or "", + v.mecenat_postal_code or "", + v.mecenat_country or "", + v.comment or "", + _fmt_dt(v.created_at), + ] + ) + + for column_cells in sheet.columns: + length = max( + (len(str(c.value)) for c in column_cells if c.value is not None), + default=8, + ) + sheet.column_dimensions[column_cells[0].column_letter].width = min( + max(length + 2, 10), 40 + ) + + buffer = io.BytesIO() + workbook.save(buffer) + return buffer.getvalue() diff --git a/backend/app/i18n/locales/fr.json b/backend/app/i18n/locales/fr.json index 00cbdb5..2e38584 100644 --- a/backend/app/i18n/locales/fr.json +++ b/backend/app/i18n/locales/fr.json @@ -49,6 +49,10 @@ "notification.no_recipients": "Aucun bénévole ciblé.", "email.magic_link.subject": "Ton lien de connexion", "email.magic_link.body": "Bonjour,\n\nVoici ton lien de connexion, valable {minutes} minutes :\n\n{link}\n\nSi tu n'es pas à l'origine de cette demande, ignore simplement cet email.", + "email.registration_confirmation.subject": "Ton inscription bénévole est confirmée", + "email.registration_confirmation.body": "Bonjour {prenom},\n\nTon inscription comme bénévole a bien été enregistrée. La coordination du festival prendra contact avec toi si besoin.\n\nÀ bientôt !", + "email.registration_confirmation_paid.subject": "Ton inscription bénévole est confirmée — paiement reçu", + "email.registration_confirmation_paid.body": "Bonjour {prenom},\n\nTon inscription comme bénévole a bien été enregistrée, et nous confirmons la réception de ton paiement :\n\n{label} — {montant} €\n\nLa coordination du festival prendra contact avec toi si besoin.\n\nÀ bientôt !", "email.planning.default_subject": "Ton planning bénévole", "email.planning.default_body": "Bonjour {prenom},\n\nTon planning a été mis à jour : {nb_creneaux} créneau(x), soit {nb_heures} h au total.\n\n{creneaux}\n\nÀ très vite sur le festival !", "email.planning.no_shifts": "Aucun créneau ne t'est affecté pour le moment.", @@ -85,6 +89,8 @@ "form.invalid_sumup_amount": "Le montant doit être supérieur à 0.", "form.unknown_payment_option": "Ce type de paiement n'existe pas pour cette édition.", "form.duplicate_payment_option_label": "Un type de paiement porte déjà ce libellé.", + "form.pending_already_paid": "Cette inscription est déjà payée.", + "form.pending_not_awaiting_info": "Cette inscription n'attend pas ces informations.", "form.too_many_position_wishes": "Deux vœux de poste au maximum.", "form.too_many_artists_same_day": "Un seul artiste souhaité par soir de festival.", "position_sheets.no_positions": "Aucun poste ne correspond à cette sélection.", diff --git a/backend/app/migrations/versions/0018_pending_submissions_mecenat.py b/backend/app/migrations/versions/0018_pending_submissions_mecenat.py new file mode 100644 index 0000000..2eaa065 --- /dev/null +++ b/backend/app/migrations/versions/0018_pending_submissions_mecenat.py @@ -0,0 +1,106 @@ +"""Inscription en deux temps + mécénat (cerfa) + +Revision ID: 0018_pending_submissions_mecenat +Revises: 0017_form_payment_options +Create Date: 2026-09-29 + +Un bénévole qui choisit un paiement en ligne n'est plus créé en base tant que +le paiement n'est pas confirmé : `pending_form_submission` retient le +formulaire (et l'état du checkout SumUp) jusqu'à la promotion en `Volunteer` +(`app/api/forms.py:_promote_pending`) ou jusqu'à purge si jamais payé. +`form_payment_option.requires_mecenat_info` distingue un type de paiement +« mécénat » (étape d'adresse postale avant paiement, cerfa) ; les coordonnées +correspondantes vivent sur `volunteer.mecenat_*` une fois le bénévole promu. +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "0018_pending_submissions_mecenat" +down_revision: str | None = "0017_form_payment_options" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +_FEE_STATUS_ENUM = sa.Enum( + "PENDING", "PAID", "FAILED", "EXPIRED", + name="feepaymentstatus", native_enum=False, +) + + +def upgrade() -> None: + op.add_column( + "form_payment_option", + sa.Column( + "requires_mecenat_info", sa.Boolean(), server_default="false", nullable=False + ), + ) + + op.add_column("volunteer", sa.Column("mecenat_address", sa.String(), nullable=True)) + op.add_column("volunteer", sa.Column("mecenat_city", sa.String(), nullable=True)) + op.add_column("volunteer", sa.Column("mecenat_postal_code", sa.String(), nullable=True)) + op.add_column("volunteer", sa.Column("mecenat_country", sa.String(), nullable=True)) + + op.create_table( + "pending_form_submission", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("edition_id", sa.Uuid(), nullable=False), + sa.Column("first_name", sa.String(), nullable=True), + sa.Column("last_name", sa.String(), nullable=True), + sa.Column("email", sa.String(), nullable=True), + sa.Column("phone", sa.String(), nullable=True), + sa.Column("comment", sa.String(), nullable=True), + sa.Column("companion_wish_note", sa.String(), nullable=True), + sa.Column("available", sa.Boolean(), server_default="false", nullable=False), + sa.Column("position_wish_ids_json", sa.String(), nullable=True), + sa.Column("artist_wish_ids_json", sa.String(), nullable=True), + sa.Column("photo", sa.LargeBinary(), nullable=True), + sa.Column("photo_content_type", sa.String(), nullable=True), + sa.Column("payment_label", sa.String(), nullable=False), + sa.Column("payment_amount", sa.Float(), nullable=False), + sa.Column( + "requires_mecenat_info", sa.Boolean(), server_default="false", nullable=False + ), + sa.Column("mecenat_address", sa.String(), nullable=True), + sa.Column("mecenat_city", sa.String(), nullable=True), + sa.Column("mecenat_postal_code", sa.String(), nullable=True), + sa.Column("mecenat_country", sa.String(), nullable=True), + sa.Column("checkout_id", sa.String(), nullable=True), + sa.Column("checkout_status", _FEE_STATUS_ENUM, nullable=True), + sa.Column("promoted_volunteer_id", sa.Uuid(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(["edition_id"], ["edition.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint( + ["promoted_volunteer_id"], ["volunteer.id"], ondelete="SET NULL" + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + "ix_pending_form_submission_edition_id", + "pending_form_submission", + ["edition_id"], + ) + op.create_index( + "ix_pending_form_submission_checkout_id", + "pending_form_submission", + ["checkout_id"], + ) + + +def downgrade() -> None: + op.drop_index( + "ix_pending_form_submission_checkout_id", table_name="pending_form_submission" + ) + op.drop_index( + "ix_pending_form_submission_edition_id", table_name="pending_form_submission" + ) + op.drop_table("pending_form_submission") + + op.drop_column("volunteer", "mecenat_country") + op.drop_column("volunteer", "mecenat_postal_code") + op.drop_column("volunteer", "mecenat_city") + op.drop_column("volunteer", "mecenat_address") + + op.drop_column("form_payment_option", "requires_mecenat_info") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 547d0d8..08eb11c 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -18,6 +18,7 @@ from app.models.domain import ( FormPaymentOption, FormTemplate, MagicLinkToken, + PendingFormSubmission, Position, Shift, Volunteer, @@ -53,6 +54,7 @@ __all__ = [ "FormPaymentOption", "FormTemplate", "MagicLinkToken", + "PendingFormSubmission", "Position", "PositionCategory", "Role", diff --git a/backend/app/models/domain.py b/backend/app/models/domain.py index 9102283..75e9b11 100644 --- a/backend/app/models/domain.py +++ b/backend/app/models/domain.py @@ -211,6 +211,15 @@ class Volunteer(TimestampMixin, table=True): sa_column=Column(SAEnum(FeePaymentStatus, native_enum=False), nullable=True), ) fee_paid_at: dt.datetime | 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`) — + # conservées pour générer un reçu fiscal (cerfa) ultérieurement. `None` + # pour un bénévole n'ayant pas payé via une option de mécénat. + mecenat_address: str | None = None + mecenat_city: str | None = None + mecenat_postal_code: str | None = None + mecenat_country: str | None = None wished_artists: list[Artist] = Relationship( back_populates="fans", @@ -545,6 +554,93 @@ class FormPaymentOption(TimestampMixin, table=True): default=0, sa_column=Column(Integer, nullable=False, server_default="0"), ) + # Type « mécénat » : avant le paiement, le bénévole passe par une étape + # supplémentaire (adresse postale) pour permettre l'édition d'un reçu + # fiscal (cerfa) — voir `Volunteer.mecenat_*` et + # `app/api/forms.py:submit_mecenat_info`. + requires_mecenat_info: bool = Field( + default=False, + sa_column=Column(Boolean, nullable=False, server_default="false"), + ) + + +class PendingFormSubmission(TimestampMixin, table=True): + """Inscription en attente de paiement en ligne (voir CLAUDE.md § 2, + `Paiement de la cotisation`). Créée à la place d'un `Volunteer` quand le + bénévole choisit un type de paiement en ligne (`FormPaymentOption`) sur + le formulaire public — le `Volunteer` n'est créé qu'à la confirmation du + paiement (`app/api/forms.py:_promote_pending`), pour ne jamais compter en + base quelqu'un qui a rempli le formulaire sans aller au bout du + paiement. + + Reprend les champs du formulaire (identiques à `Volunteer`, en plus + volatil) ; `position_wish_ids_json`/`artist_wish_ids_json` : listes + JSON stringifiées (pas d'association SQL pour une ligne éphémère), leurs + UUID sont résolus (silencieusement filtrés si un poste/artiste a + entretemps disparu — le paiement ne doit jamais échouer pour ça) au + moment de la promotion en `Volunteer`. + + `id` sert aussi de jeton public (`GET .../form/public/pending/{id}`, + aucune authentification) : un UUID v4 est déjà pratiquement impossible + à deviner, comme un identifiant de session de paiement chez la plupart + des prestataires. + + `payment_label`/`payment_amount`/`requires_mecenat_info` sont une copie + figée de l'option choisie (même principe que `Volunteer.fee_payment_label`) + — indépendante d'une modification de `FormPaymentOption` pendant que le + paiement est en cours. `checkout_id`/`checkout_status` suivent le + checkout SumUp *courant* : relancés (nouvel identifiant) à chaque + nouvelle tentative après un échec/une expiration + (`app/api/forms.py:retry_pending_payment`), sans jamais perdre les + informations déjà saisies. + + `promoted_volunteer_id` : posé au moment du paiement confirmé (`PAID`) — + le `Volunteer` créé. Les champs nominatifs sont alors vidés (RGPD : + la même information vit désormais uniquement sur `Volunteer`), seuls + les champs de suivi du paiement restent. Une ligne jamais promue est + purgée après un délai (voir `_prune_stale_pending`).""" + + __tablename__ = "pending_form_submission" + + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + edition_id: uuid.UUID = Field( + foreign_key="edition.id", index=True, ondelete="CASCADE" + ) + + first_name: str | None = None + last_name: str | None = None + email: str | None = None + phone: str | None = None + comment: str | None = None + companion_wish_note: str | None = None + available: bool = Field( + default=False, + sa_column=Column(Boolean, nullable=False, server_default="false"), + ) + position_wish_ids_json: str | None = None + artist_wish_ids_json: str | None = None + photo: bytes | None = Field(default=None, sa_column=Column(LargeBinary)) + photo_content_type: str | None = None + + payment_label: str + payment_amount: float + requires_mecenat_info: bool = Field( + default=False, + sa_column=Column(Boolean, nullable=False, server_default="false"), + ) + mecenat_address: str | None = None + mecenat_city: str | None = None + mecenat_postal_code: str | None = None + mecenat_country: str | None = None + + checkout_id: str | None = Field(default=None, index=True) + checkout_status: FeePaymentStatus | None = Field( + default=None, + sa_column=Column(SAEnum(FeePaymentStatus, native_enum=False), nullable=True), + ) + promoted_volunteer_id: uuid.UUID | None = Field( + default=None, foreign_key="volunteer.id", ondelete="SET NULL" + ) class FormField(SQLModel, table=True): diff --git a/backend/tests/mail.py b/backend/tests/mail.py new file mode 100644 index 0000000..7b3c211 --- /dev/null +++ b/backend/tests/mail.py @@ -0,0 +1,10 @@ +"""Aide de test : les messages capturés par `outbox` (conftest.py) sont +`multipart/alternative` depuis l'ajout d'une version HTML (voir +`app/email/sender.py:build_message`) — `.get_content()` ne suffit plus à lui +seul, il faut préciser quelle partie lire.""" + +from email.message import EmailMessage + + +def plain_text(message: EmailMessage) -> str: + return message.get_body(preferencelist=("plain",)).get_content() diff --git a/backend/tests/test_api_notifications.py b/backend/tests/test_api_notifications.py index 27f32b2..07c6e51 100644 --- a/backend/tests/test_api_notifications.py +++ b/backend/tests/test_api_notifications.py @@ -2,6 +2,7 @@ from sqlmodel import select from app.models import Assignment, MagicLinkToken, Role from tests.factories import dt_at, make_position, make_shift, make_volunteer +from tests.mail import plain_text def _url(edition): @@ -38,17 +39,17 @@ def test_one_email_per_volunteer_with_an_address(client, edition, session, outbo by_to = {m["To"]: m for m in outbox} assert by_to["ana@e.fr"]["Subject"] == "Planning de Ana" - ana_body = by_to["ana@e.fr"].get_content() + ana_body = plain_text(by_to["ana@e.fr"]) assert "Tu as 2 créneau(x), 7.0 h." in ana_body assert "Bar" in ana_body - assert "Tu as 0 créneau(x), 0.0 h." in by_to["bob@e.fr"].get_content() + assert "Tu as 0 créneau(x), 0.0 h." in plain_text(by_to["bob@e.fr"]) def test_default_template_is_used_when_body_omitted(client, edition, session, outbox): make_volunteer(session, edition, first_name="Ana", email="ana@e.fr") resp = client.post(_url(edition), json={}) assert resp.status_code == 200 - assert outbox[0].get_content().startswith("Bonjour Ana,") + assert plain_text(outbox[0]).startswith("Bonjour Ana,") def test_magic_link_is_embedded_when_placeholder_present( @@ -61,7 +62,7 @@ def test_magic_link_is_embedded_when_placeholder_present( _url(edition), json={"body": "Connecte-toi : {lien_connexion}"} ) assert resp.status_code == 200 - assert "/connexion?token=" in outbox[0].get_content() + assert "/connexion?token=" in plain_text(outbox[0]) assert len(session.exec(select(MagicLinkToken)).all()) == 1 diff --git a/backend/tests/test_api_volunteers.py b/backend/tests/test_api_volunteers.py index 9d2cd67..b0ba522 100644 --- a/backend/tests/test_api_volunteers.py +++ b/backend/tests/test_api_volunteers.py @@ -1,6 +1,8 @@ import datetime as dt +import io import uuid +from openpyxl import load_workbook from sqlmodel import select from app.models import Assignment, Availability, Role, Volunteer @@ -389,3 +391,42 @@ def test_lead_can_read_any_volunteers_planning( client = as_account(lead_account) assert client.get(_url(edition, f"/{other.id}/planning")).status_code == 200 + + +def test_export_volunteers_as_excel(client, edition, session): + position = make_position(session, edition, name="Bar central") + artist = make_artist(session, edition, name="Chilla") + volunteer = make_volunteer( + session, edition, first_name="Alex", last_name="Martin", email="alex@ex.fr" + ) + client.patch( + _url(edition, f"/{volunteer.id}"), + json={ + "wished_position_ids": [str(position.id)], + "artist_ids": [str(artist.id)], + }, + ) + + resp = client.get(_url(edition, "/export")) + assert resp.status_code == 200 + assert resp.headers["content-type"] == ( + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + ) + assert f"benevoles-{edition.year}.xlsx" in resp.headers["content-disposition"] + + workbook = load_workbook(io.BytesIO(resp.content)) + sheet = workbook.active + rows = list(sheet.iter_rows(values_only=True)) + assert rows[0][0] == "Prénom" + row = rows[1] + assert row[0] == "Alex" + assert row[1] == "Martin" + assert row[2] == "alex@ex.fr" + assert row[5] == "Bar central" + assert row[6] == "Chilla" + + +def test_export_volunteers_is_coordinator_only(edition, make_account, as_account): + lead = make_account(Role.LEAD, email="lead@e.fr") + resp = as_account(lead).get(_url(edition, "/export")) + assert resp.status_code == 403 diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index 0e1df38..93096af 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -4,6 +4,7 @@ from sqlmodel import select from app.auth.tokens import hash_token from app.models import MagicLinkToken, Role, local_now +from tests.mail import plain_text def _url(edition, suffix): @@ -31,7 +32,7 @@ def test_full_flow_magic_link_then_jwt_then_me( assert resp.status_code == 202 assert len(outbox) == 1 assert outbox[0]["To"] == "ref@example.fr" - raw_token = outbox[0].get_content().split("?token=")[1].split()[0].strip() + raw_token = plain_text(outbox[0]).split("?token=")[1].split()[0].strip() verified = raw_client.post( _url(edition, "verify"), json={"token": raw_token} diff --git a/backend/tests/test_forms_api.py b/backend/tests/test_forms_api.py index 7d36699..3cf832a 100644 --- a/backend/tests/test_forms_api.py +++ b/backend/tests/test_forms_api.py @@ -4,11 +4,13 @@ soumission publique (aucune authentification).""" import base64 import datetime as dt import io +import uuid from app.auth.rate_limit import form_submission_limiter -from app.models import Role +from app.models import PendingFormSubmission, Role, local_now from app.sumup import SumUpCheckout from tests.factories import make_artist, make_position +from tests.mail import plain_text _MERCHANT = "MXXXXX" _API_KEY = "sup_sk_secret" @@ -27,6 +29,24 @@ def _configure_sumup(client, edition, amount=12.5, label="Cotisation classique") ) return resp.json()["id"] + +def _pay(raw_client, edition, monkeypatch, checkout_id="co_1", status_after="PAID"): + """Monte le checkout ensuite renvoyé par `get_checkout` sur `status_after` + (`PAID`/`FAILED`/`EXPIRED`) — les tests posent d'abord `create_checkout` + et soumettent eux-mêmes, cette aide ne fait que la partie webhook.""" + monkeypatch.setattr( + "app.api.forms.get_checkout", + lambda **kw: SumUpCheckout(id=checkout_id, status=status_after, hosted_checkout_url=None), + ) + return raw_client.post( + _form_url(edition, "/public/sumup-webhook"), + json={"event_type": "CHECKOUT_STATUS_CHANGED", "id": checkout_id}, + ) + + +def _pending_status(raw_client, edition, token): + return raw_client.get(_form_url(edition, f"/public/pending/{token}")).json() + # PNG 1×1 valide (transparent), le plus petit possible. _PNG = base64.b64decode( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY" @@ -202,7 +222,9 @@ def test_deleting_a_payment_option_keeps_the_volunteers_snapshot( first_name="Alex", last_name="Martin", hcaptcha_token="x", payment_option_id=option_id, ) - volunteer_id = resp.json()["volunteer_id"] + token = resp.json()["pending_token"] + _pay(raw_client, edition, monkeypatch, checkout_id="co_1") + volunteer_id = _pending_status(raw_client, edition, token)["volunteer_id"] client.delete(_form_url(edition, f"/payment-options/{option_id}")) @@ -294,11 +316,12 @@ def test_submitting_creates_a_volunteer(raw_client, client, edition, session): _form_url(edition, "/fields"), json={"fields": ["EMAIL", "PHONE", "AVAILABLE", "WILLING_TO_PAY_FEE"]}, ) + # Pas de payment_option_id : « je paierai sur place » — pas de paiement + # en ligne, le bénévole est créé tout de suite. resp = _submit( raw_client, edition, first_name="alex", last_name="martin", hcaptcha_token="x", - email="alex@example.org", phone="0600000000", - available="true", willing_to_pay_fee="true", + email="alex@example.org", phone="0600000000", available="true", ) assert resp.status_code == 201 volunteer_id = resp.json()["volunteer_id"] @@ -309,12 +332,32 @@ def test_submitting_creates_a_volunteer(raw_client, client, edition, session): assert fiche["email"] == "alex@example.org" assert fiche["self_declared_available"] is True assert fiche["willing_to_pay_fee"] is True + assert fiche["fee_payment_status"] is None # Le bénévole créé apparaît dans la liste de la coordination. volunteers = client.get(f"/editions/{edition.id}/volunteers").json() assert any(v["id"] == volunteer_id for v in volunteers) +def test_submitting_sends_a_registration_confirmation_email(raw_client, client, edition, outbox): + client.put(_form_url(edition, "/fields"), json={"fields": ["EMAIL"]}) + resp = _submit( + raw_client, edition, + first_name="Alex", last_name="Martin", hcaptcha_token="x", + email="alex@example.org", + ) + assert resp.status_code == 201 + assert len(outbox) == 1 + assert outbox[0]["To"] == "alex@example.org" + assert "Alex" in plain_text(outbox[0]) + + +def test_submitting_without_an_email_sends_no_confirmation(raw_client, edition, outbox): + resp = _submit(raw_client, edition, first_name="Alex", last_name="Martin", hcaptcha_token="x") + assert resp.status_code == 201 + assert outbox == [] + + def test_submitting_ignores_fields_not_enabled(raw_client, client, edition): # Aucun champ activé : email/téléphone envoyés quand même, ignorés. resp = _submit( @@ -329,6 +372,35 @@ def test_submitting_ignores_fields_not_enabled(raw_client, client, edition): assert fiche["phone"] is None +def test_submitting_without_an_email_is_rejected_when_email_enabled(raw_client, client, edition): + client.put(_form_url(edition, "/fields"), json={"fields": ["EMAIL"]}) + resp = _submit(raw_client, edition, first_name="Alex", last_name="Martin", hcaptcha_token="x") + assert resp.status_code == 422 + assert resp.json()["detail"]["code"] == "import.row.missing_required_field" + + +def test_submitting_without_a_phone_is_rejected_when_phone_enabled(raw_client, client, edition): + client.put(_form_url(edition, "/fields"), json={"fields": ["PHONE"]}) + resp = _submit(raw_client, edition, first_name="Alex", last_name="Martin", hcaptcha_token="x") + assert resp.status_code == 422 + assert resp.json()["detail"]["code"] == "import.row.missing_required_field" + + +def test_submitting_without_checking_available_is_rejected_when_enabled( + raw_client, client, edition +): + client.put(_form_url(edition, "/fields"), json={"fields": ["AVAILABLE"]}) + resp = _submit(raw_client, edition, first_name="Alex", last_name="Martin", hcaptcha_token="x") + assert resp.status_code == 422 + assert resp.json()["detail"]["code"] == "import.row.missing_required_field" + + resp = _submit( + raw_client, edition, + first_name="Alex", last_name="Martin", hcaptcha_token="x", available="true", + ) + assert resp.status_code == 201 + + def test_submitting_resolves_position_and_artist_wishes( raw_client, client, edition, session ): @@ -482,10 +554,11 @@ def test_submitting_with_a_rejected_captcha(raw_client, edition, monkeypatch): assert resp.json()["detail"]["code"] == "form.captcha_failed" -def test_submitting_creates_a_sumup_checkout_when_a_payment_option_is_chosen( +def test_choosing_an_online_option_does_not_create_the_volunteer_yet( raw_client, client, edition, monkeypatch ): option_id = _configure_sumup(client, edition) + before = len(client.get(f"/editions/{edition.id}/volunteers").json()) monkeypatch.setattr( "app.api.forms.create_checkout", lambda **kw: SumUpCheckout( @@ -499,16 +572,86 @@ def test_submitting_creates_a_sumup_checkout_when_a_payment_option_is_chosen( ) assert resp.status_code == 201 body = resp.json() + assert body["volunteer_id"] is None + assert body["pending_token"] is not None assert body["sumup_checkout_url"] == "https://checkout.sumup.com/pay/co_123" - fiche = client.get(f"/editions/{edition.id}/volunteers/{body['volunteer_id']}").json() - assert fiche["fee_payment_status"] == "PENDING" + # Toujours rien en base tant que le paiement n'est pas confirmé. + after = len(client.get(f"/editions/{edition.id}/volunteers").json()) + assert after == before + + status = _pending_status(raw_client, edition, body["pending_token"]) + assert status["status"] == "PENDING" + assert status["label"] == "Cotisation classique" + assert status["amount"] == 12.5 + + +def test_paying_promotes_the_pending_submission_to_a_volunteer( + raw_client, client, edition, monkeypatch +): + option_id = _configure_sumup(client, edition) + monkeypatch.setattr( + "app.api.forms.create_checkout", + lambda **kw: SumUpCheckout(id="co_123", status="PENDING", hosted_checkout_url="https://x"), + ) + resp = _submit( + raw_client, edition, + first_name="Alex", last_name="Martin", hcaptcha_token="x", + payment_option_id=option_id, + ) + token = resp.json()["pending_token"] + + webhook_resp = _pay(raw_client, edition, monkeypatch, checkout_id="co_123") + assert webhook_resp.status_code == 200 + + status = _pending_status(raw_client, edition, token) + assert status["status"] == "PAID" + volunteer_id = status["volunteer_id"] + assert volunteer_id is not None + + fiche = client.get(f"/editions/{edition.id}/volunteers/{volunteer_id}").json() + assert fiche["first_name"] == "Alex" + assert fiche["fee_payment_status"] == "PAID" assert fiche["fee_payment_label"] == "Cotisation classique" assert fiche["fee_amount"] == 12.5 + assert fiche["fee_paid_at"] is not None assert fiche["willing_to_pay_fee"] is True -def test_submitting_without_choosing_an_option_does_not_create_a_checkout( +def test_payment_confirmation_sends_an_email_on_promotion( + raw_client, client, edition, monkeypatch, outbox +): + client.put(_form_url(edition, "/fields"), json={"fields": ["WILLING_TO_PAY_FEE", "EMAIL"]}) + client.patch( + _form_url(edition), json={"sumup_merchant_code": _MERCHANT, "sumup_api_key": _API_KEY} + ) + option_id = client.post( + _form_url(edition, "/payment-options"), + json={"label": "Cotisation classique", "amount": 12.5}, + ).json()["id"] + monkeypatch.setattr( + "app.api.forms.create_checkout", + lambda **kw: SumUpCheckout(id="co_1", status="PENDING", hosted_checkout_url="https://x"), + ) + _submit( + raw_client, edition, + first_name="Alex", last_name="Martin", hcaptcha_token="x", + email="alex@example.org", + payment_option_id=option_id, + ) + assert outbox == [] # pas encore payé : pas d'email à ce stade + + _pay(raw_client, edition, monkeypatch, checkout_id="co_1") + + assert len(outbox) == 1 + assert outbox[0]["To"] == "alex@example.org" + body = plain_text(outbox[0]) + assert "Alex" in body + assert "Cotisation classique" in body + assert "12.5" in body + + +def test_choosing_to_pay_on_site_creates_the_volunteer_immediately( raw_client, client, edition, monkeypatch ): _configure_sumup(client, edition) @@ -518,9 +661,16 @@ def test_submitting_without_choosing_an_option_does_not_create_a_checkout( raw_client, edition, first_name="Alex", last_name="Martin", hcaptcha_token="x" ) assert resp.status_code == 201 - assert resp.json()["sumup_checkout_url"] is None + body = resp.json() + assert body["volunteer_id"] is not None + assert body["pending_token"] is None + assert body["sumup_checkout_url"] is None assert called == [] + fiche = client.get(f"/editions/{edition.id}/volunteers/{body['volunteer_id']}").json() + assert fiche["willing_to_pay_fee"] is True + assert fiche["fee_payment_status"] is None + def test_submitting_with_an_unknown_payment_option_is_rejected(raw_client, client, edition): _configure_sumup(client, edition) @@ -533,7 +683,7 @@ def test_submitting_with_an_unknown_payment_option_is_rejected(raw_client, clien assert resp.json()["detail"]["code"] == "form.unknown_payment_option" -def test_a_failed_checkout_call_does_not_block_registration( +def test_a_failed_checkout_call_can_be_retried_without_resubmitting( raw_client, client, edition, monkeypatch ): option_id = _configure_sumup(client, edition) @@ -546,36 +696,151 @@ def test_a_failed_checkout_call_does_not_block_registration( assert resp.status_code == 201 body = resp.json() assert body["sumup_checkout_url"] is None - fiche = client.get(f"/editions/{edition.id}/volunteers/{body['volunteer_id']}").json() - assert fiche["fee_payment_status"] is None + token = body["pending_token"] + assert _pending_status(raw_client, edition, token)["status"] == "PENDING" + + # SumUp remarche : la relance réutilise les infos déjà saisies. + monkeypatch.setattr( + "app.api.forms.create_checkout", + lambda **kw: SumUpCheckout(id="co_retry", status="PENDING", hosted_checkout_url="https://x"), + ) + retry_resp = raw_client.post(_form_url(edition, f"/public/pending/{token}/retry-payment")) + assert retry_resp.status_code == 200 + assert retry_resp.json()["sumup_checkout_url"] == "https://x" + + _pay(raw_client, edition, monkeypatch, checkout_id="co_retry") + assert _pending_status(raw_client, edition, token)["status"] == "PAID" -def test_sumup_webhook_updates_the_payment_status(raw_client, client, edition, monkeypatch): +def test_retrying_after_an_expired_checkout(raw_client, client, edition, monkeypatch): option_id = _configure_sumup(client, edition) monkeypatch.setattr( "app.api.forms.create_checkout", - lambda **kw: SumUpCheckout(id="co_123", status="PENDING", hosted_checkout_url="https://x"), + lambda **kw: SumUpCheckout(id="co_1", status="PENDING", hosted_checkout_url="https://x"), ) resp = _submit( raw_client, edition, first_name="Alex", last_name="Martin", hcaptcha_token="x", payment_option_id=option_id, ) - volunteer_id = resp.json()["volunteer_id"] + token = resp.json()["pending_token"] + + _pay(raw_client, edition, monkeypatch, checkout_id="co_1", status_after="EXPIRED") + assert _pending_status(raw_client, edition, token)["status"] == "EXPIRED" monkeypatch.setattr( - "app.api.forms.get_checkout", - lambda **kw: SumUpCheckout(id="co_123", status="PAID", hosted_checkout_url=None), + "app.api.forms.create_checkout", + lambda **kw: SumUpCheckout(id="co_2", status="PENDING", hosted_checkout_url="https://y"), ) - webhook_resp = raw_client.post( - _form_url(edition, "/public/sumup-webhook"), - json={"event_type": "CHECKOUT_STATUS_CHANGED", "id": "co_123"}, - ) - assert webhook_resp.status_code == 200 + retry_resp = raw_client.post(_form_url(edition, f"/public/pending/{token}/retry-payment")) + assert retry_resp.status_code == 200 + +def test_retrying_an_already_paid_pending_submission_is_rejected( + 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"), + ) + resp = _submit( + raw_client, edition, + first_name="Alex", last_name="Martin", hcaptcha_token="x", + payment_option_id=option_id, + ) + token = resp.json()["pending_token"] + _pay(raw_client, edition, monkeypatch, checkout_id="co_1") + + retry_resp = raw_client.post(_form_url(edition, f"/public/pending/{token}/retry-payment")) + assert retry_resp.status_code == 422 + assert retry_resp.json()["detail"]["code"] == "form.pending_already_paid" + + +def test_an_unknown_pending_token_is_a_404(raw_client, edition): + resp = raw_client.get( + _form_url(edition, "/public/pending/00000000-0000-0000-0000-000000000000") + ) + assert resp.status_code == 404 + + +def test_choosing_a_mecenat_option_requires_the_address_step_first( + raw_client, client, edition, monkeypatch +): + client.put(_form_url(edition, "/fields"), json={"fields": ["WILLING_TO_PAY_FEE"]}) + client.patch( + _form_url(edition), json={"sumup_merchant_code": _MERCHANT, "sumup_api_key": _API_KEY} + ) + option_id = client.post( + _form_url(edition, "/payment-options"), + json={"label": "Mécénat", "amount": 50, "requires_mecenat_info": True}, + ).json()["id"] + + called = [] + monkeypatch.setattr("app.api.forms.create_checkout", lambda **kw: called.append(kw)) + resp = _submit( + raw_client, edition, + first_name="Alex", last_name="Martin", hcaptcha_token="x", + payment_option_id=option_id, + ) + assert resp.status_code == 201 + body = resp.json() + assert body["requires_mecenat_info"] is True + assert body["sumup_checkout_url"] is None + assert called == [] # pas de checkout avant d'avoir l'adresse + + token = body["pending_token"] + monkeypatch.setattr( + "app.api.forms.create_checkout", + lambda **kw: SumUpCheckout(id="co_don", status="PENDING", hosted_checkout_url="https://don"), + ) + info_resp = raw_client.post( + _form_url(edition, f"/public/pending/{token}/mecenat-info"), + json={ + "address": "1 rue du Festival", + "city": "Rennes", + "postal_code": "35000", + "country": "France", + }, + ) + assert info_resp.status_code == 200 + assert info_resp.json()["sumup_checkout_url"] == "https://don" + + _pay(raw_client, edition, monkeypatch, checkout_id="co_don") + volunteer_id = _pending_status(raw_client, edition, token)["volunteer_id"] fiche = client.get(f"/editions/{edition.id}/volunteers/{volunteer_id}").json() - assert fiche["fee_payment_status"] == "PAID" - assert fiche["fee_paid_at"] is not None + assert fiche["mecenat_address"] == "1 rue du Festival" + assert fiche["mecenat_city"] == "Rennes" + assert fiche["mecenat_postal_code"] == "35000" + assert fiche["mecenat_country"] == "France" + + +def test_mecenat_info_requires_every_field(raw_client, client, edition, monkeypatch): + client.put(_form_url(edition, "/fields"), json={"fields": ["WILLING_TO_PAY_FEE"]}) + client.patch( + _form_url(edition), json={"sumup_merchant_code": _MERCHANT, "sumup_api_key": _API_KEY} + ) + option_id = client.post( + _form_url(edition, "/payment-options"), + json={"label": "Mécénat", "amount": 50, "requires_mecenat_info": True}, + ).json()["id"] + resp = _submit( + raw_client, edition, + first_name="Alex", last_name="Martin", hcaptcha_token="x", + payment_option_id=option_id, + ) + token = resp.json()["pending_token"] + + info_resp = raw_client.post( + _form_url(edition, f"/public/pending/{token}/mecenat-info"), + json={ + "address": "1 rue du Festival", + "city": "Rennes", + "postal_code": "", + "country": "France", + }, + ) + assert info_resp.status_code == 422 def test_sumup_webhook_ignores_an_unknown_checkout(raw_client, client, edition, monkeypatch): @@ -591,6 +856,27 @@ def test_sumup_webhook_ignores_an_unknown_checkout(raw_client, client, edition, assert resp.status_code == 200 +def test_stale_pending_submissions_are_pruned(raw_client, client, edition, session, monkeypatch): + option_id = _configure_sumup(client, edition) + monkeypatch.setattr("app.api.forms.create_checkout", lambda **kw: None) + resp = _submit( + raw_client, edition, + first_name="Alex", last_name="Martin", hcaptcha_token="x", + payment_option_id=option_id, + ) + token = resp.json()["pending_token"] + + pending = session.get(PendingFormSubmission, uuid.UUID(token)) + pending.created_at = local_now() - dt.timedelta(hours=49) + session.add(pending) + session.commit() + + # N'importe quelle nouvelle soumission déclenche le nettoyage paresseux. + _submit(raw_client, edition, first_name="Sam", last_name="Dupuis", hcaptcha_token="x") + + assert raw_client.get(_form_url(edition, f"/public/pending/{token}")).status_code == 404 + + def test_submitting_is_rate_limited_per_edition(raw_client, edition): for _ in range(form_submission_limiter.max_attempts): form_submission_limiter.hit(str(edition.id)) diff --git a/backend/tests/test_models.py b/backend/tests/test_models.py index 19a8354..4313982 100644 --- a/backend/tests/test_models.py +++ b/backend/tests/test_models.py @@ -32,6 +32,7 @@ EXPECTED_TABLES = { "form_template", "form_field", "form_payment_option", + "pending_form_submission", } diff --git a/frontend/scripts/smoke.tsx b/frontend/scripts/smoke.tsx index 061954c..18b3ecd 100644 --- a/frontend/scripts/smoke.tsx +++ b/frontend/scripts/smoke.tsx @@ -140,6 +140,7 @@ async function checkPublicForm() { first_name: 'Test', last_name: 'Smoke', hcaptcha_token: 'dev', + email: 'test.smoke@example.org', artist_wish_ids: [sameDay[0].id, sameDay[1].id], }); } catch (err) { @@ -154,6 +155,7 @@ async function checkPublicForm() { first_name: 'Test', last_name: 'Smoke', hcaptcha_token: 'dev', + email: 'test.smoke@example.org', position_wish_ids: positions.slice(0, 3).map((p) => p.id), }); } catch (err) { @@ -161,6 +163,8 @@ async function checkPublicForm() { } } + // Paiement en ligne choisi : rien en base tant que la « banque » (mock) ne + // confirme pas — voir CLAUDE.md § 2, Paiement de la cotisation. const result = await api.forms.submitPublic(e, { first_name: 'test', last_name: 'smoke', @@ -171,7 +175,22 @@ async function checkPublicForm() { artist_wish_ids: artists[0] ? [artists[0].id] : [], payment_option_id: classique.id, }); - const created = await api.volunteers.get(e, result.volunteer_id); + if (result.volunteer_id || !result.pending_token) { + throw new Error('formulaire public : un paiement en ligne ne devrait pas créer le bénévole tout de suite'); + } + const midCount = (await api.volunteers.list(e)).length; + if (midCount !== beforeCount) { + throw new Error('formulaire public : le bénévole ne devrait pas encore compter en base'); + } + + let status = await api.forms.getPendingStatus(e, result.pending_token); + if (status.status === 'PENDING') { + status = await api.forms.getPendingStatus(e, result.pending_token); + } + if (status.status !== 'PAID' || !status.volunteer_id) { + throw new Error('formulaire public : le paiement simulé ne se confirme pas'); + } + const created = await api.volunteers.get(e, status.volunteer_id); const afterCount = (await api.volunteers.list(e)).length; console.log( diff --git a/frontend/src/api/hooks.ts b/frontend/src/api/hooks.ts index 1e2bc91..f0217f8 100644 --- a/frontend/src/api/hooks.ts +++ b/frontend/src/api/hooks.ts @@ -28,6 +28,7 @@ import type { FormPaymentOptionUpdate, FormTemplateUpdate, ImportMapping, + MecenatInfoSubmit, PlanningNotificationRequest, PositionCreate, PositionSheetsRequest, @@ -384,6 +385,15 @@ export function useDeleteVolunteer() { }); } +/** Export Excel de la liste des bénévoles ; ne fait que retourner le + * fichier (le composant appelant déclenche le téléchargement, voir + * `useGenerateBadges`). */ +export function useExportVolunteers() { + return useMutation({ + mutationFn: () => api.volunteers.exportXlsx(edition), + }); +} + /** Remplace toutes les fenêtres de disponibilité d'un bénévole. */ export function useReplaceAvailability() { const invalidate = useInvalidate(); @@ -713,3 +723,31 @@ export function useSubmitPublicForm() { mutationFn: (body: PublicFormSubmission) => api.forms.submitPublic(edition, body), }); } + +export function useSubmitMecenatInfo() { + return useMutation({ + mutationFn: ({ token, body }: { token: string; body: MecenatInfoSubmit }) => + api.forms.submitMecenatInfo(edition, token, body), + }); +} + +/** Statut d'une inscription en attente de paiement en ligne — interrogé en + * direct (voir CLAUDE.md § 2, Paiement de la cotisation) : la page de + * confirmation reste en attente tant que `status` vaut `PENDING`. */ +export function usePendingStatus(token: string | null) { + return useQuery({ + queryKey: ['pendingStatus', edition, token], + queryFn: () => api.forms.getPendingStatus(edition, token as string), + enabled: token !== null, + // Arrête la vérification automatique après ~2 min (40 x 3 s) : au-delà, + // un bouton manuel dans PaymentConfirmationView permet de réessayer. + refetchInterval: (query) => + query.state.data?.status === 'PENDING' && query.state.dataUpdateCount < 40 ? 3000 : false, + }); +} + +export function useRetryPendingPayment() { + return useMutation({ + mutationFn: (token: string) => api.forms.retryPendingPayment(edition, token), + }); +} diff --git a/frontend/src/api/http-client.ts b/frontend/src/api/http-client.ts index bdb4e12..14b2638 100644 --- a/frontend/src/api/http-client.ts +++ b/frontend/src/api/http-client.ts @@ -20,6 +20,7 @@ import type { ImportMapping, LoginRequest, MagicLinkRequest, + MecenatInfoSubmit, PlanningNotificationRequest, PositionCreate, PositionSheetsRequest, @@ -151,6 +152,11 @@ export function createHttpApi(baseUrl: string): PlanningApi { planning: (editionId, id) => request(`${base(editionId)}/volunteers/${id}/planning`), photoBlob: (editionId, id) => requestBlob(`${base(editionId)}/volunteers/${id}/photo`), + exportXlsx: async (editionId) => { + const blob = await requestBlob(`${base(editionId)}/volunteers/export`); + if (!blob) throw new ApiError(500, 'error.generic', []); + return blob; + }, }, accounts: { list: (editionId) => request(`${base(editionId)}/accounts`), @@ -295,9 +301,6 @@ export function createHttpApi(baseUrl: string): PlanningApi { if (body.comment) form.append('comment', body.comment); if (body.companion_wish_note) form.append('companion_wish_note', body.companion_wish_note); if (body.available !== undefined) form.append('available', String(body.available)); - if (body.willing_to_pay_fee !== undefined) { - form.append('willing_to_pay_fee', String(body.willing_to_pay_fee)); - } if (body.payment_option_id) form.append('payment_option_id', body.payment_option_id); if (body.position_wish_ids) { form.append('position_wish_ids', JSON.stringify(body.position_wish_ids)); @@ -323,6 +326,17 @@ export function createHttpApi(baseUrl: string): PlanningApi { remove: (editionId, id) => request(`${base(editionId)}/form/payment-options/${id}`, { method: 'DELETE' }), }, + submitMecenatInfo: (editionId, token, body: MecenatInfoSubmit) => + request(`${base(editionId)}/form/public/pending/${token}/mecenat-info`, { + method: 'POST', + body: JSON.stringify(body), + }), + getPendingStatus: (editionId, token) => + request(`${base(editionId)}/form/public/pending/${token}`), + retryPendingPayment: (editionId, token) => + request(`${base(editionId)}/form/public/pending/${token}/retry-payment`, { + method: 'POST', + }), }, positionSheets: { generate: async (editionId, body: PositionSheetsRequest) => { diff --git a/frontend/src/api/mock/dataset.ts b/frontend/src/api/mock/dataset.ts index 9f27f6b..77184b7 100644 --- a/frontend/src/api/mock/dataset.ts +++ b/frontend/src/api/mock/dataset.ts @@ -8,6 +8,7 @@ import type { BadgeFieldRead, DayPartRead, EditionRead, + FeePaymentStatus, FormFieldType, FormPaymentOptionRead, PositionCategory, @@ -16,6 +17,37 @@ import type { VolunteerRead, } from '../schema'; +/** Inscription en attente de paiement en ligne (mode démonstration) — voir + * `PendingFormSubmission` côté back-end (CLAUDE.md § 2, Paiement de la + * cotisation). `pollCount` simule le délai de confirmation réel (webhook + * asynchrone) : la première vérification reste `PENDING`, la suivante + * confirme — sans vrai réseau ni vrai SumUp en mode mock. */ +export interface MockPendingSubmission { + id: string; + editionId: string; + firstName: string | null; + lastName: string | null; + email: string | null; + phone: string | null; + comment: string | null; + companionWishNote: string | null; + available: boolean; + positionIds: string[]; + artistIds: string[]; + photo: string | null; // data URL + paymentLabel: string; + paymentAmount: number; + requiresMecenatInfo: boolean; + mecenatAddress: string | null; + mecenatCity: string | null; + mecenatPostalCode: string | null; + mecenatCountry: string | null; + checkoutId: string | null; + status: FeePaymentStatus | null; + promotedVolunteerId: string | null; + pollCount: number; +} + const PART_LABELS: Record = { M: 'Matin', A: 'Après-midi', S: 'Soirée', N: 'Nuit' }; const PART_CODES: Record = { M: 'MATIN', A: 'APRES_MIDI', S: 'SOIREE', N: 'NUIT' }; const hhmm = (h: number) => `${String(h % 24).padStart(2, '0')}:00:00`; @@ -61,6 +93,9 @@ export interface MockState { sumupApiKey: string | null; }; formPaymentOptions: FormPaymentOptionRead[]; + /** Inscriptions en attente de paiement en ligne (voir CLAUDE.md § 2, + * Paiement de la cotisation) — clé : jeton public (id). */ + pendingSubmissions: Map; /** volunteerId -> photo (formulaire public), data URL. */ photos: Map; seq: number; @@ -278,6 +313,10 @@ export function buildMockState(): MockState { willing_to_pay_fee: false, fee_payment_label: null, fee_amount: null, + mecenat_address: null, + mecenat_city: null, + mecenat_postal_code: null, + mecenat_country: null, fee_payment_status: null, fee_paid_at: null, created_at: NOW, @@ -458,6 +497,7 @@ export function buildMockState(): MockState { sumupMerchantCode: null, sumupApiKey: null, }, formPaymentOptions: [], + pendingSubmissions: new Map(), photos: new Map(), seq, }; diff --git a/frontend/src/api/mock/mock-client.ts b/frontend/src/api/mock/mock-client.ts index ce89451..0b8297d 100644 --- a/frontend/src/api/mock/mock-client.ts +++ b/frontend/src/api/mock/mock-client.ts @@ -32,6 +32,8 @@ import type { ImportReport, ImportRowReport, MatrixResponse, + MecenatInfoSubmit, + PendingSubmissionStatus, PlanningNotificationRequest, PositionSheetsRequest, PublicFormConfig, @@ -45,7 +47,7 @@ import type { } from '../schema'; import { getAuthToken } from '../session'; import { ApiError, type PlanningApi } from '../types'; -import { buildMockState, type MockState } from './dataset'; +import { buildMockState, type MockPendingSubmission, type MockState } from './dataset'; // Implémentation en mémoire de `PlanningApi`. Un délai réseau simulé rend le // comportement (chargements, invalidations) proche du back-end réel. @@ -159,6 +161,14 @@ const MOCK_PDF_BLOB = () => { type: 'application/pdf' }, ); +// Contenu factice (pas un vrai classeur) — la génération réelle (openpyxl) +// est réservée au back-end ; en mode démonstration, seul le déclenchement +// du téléchargement est simulé. +const MOCK_XLSX_BLOB = () => + new Blob(['Export bénévoles (démonstration)'], { + type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + }); + const touch = () => new Date().toISOString().replace(/\.\d+Z$/, ''); const addDays = (day: string, n: number): string => { @@ -197,6 +207,74 @@ function resolveWishes( return wanted; } +/** Comme `resolveWishes` mais silencieux : filtre les ids toujours valides + * sans jamais lever — utilisé à la promotion d'une inscription en attente + * (le paiement a déjà eu lieu, voir `promotePending`). */ +function filterKnownIds(pool: T[], ids: string[]): string[] { + const known = new Set(pool.map((x) => x.id)); + return [...new Set(ids)].filter((id) => known.has(id)); +} + +/** Paiement confirmé : crée enfin le `Volunteer` (voir le docstring de + * `PendingFormSubmission` côté back-end) puis vide les champs nominatifs de + * l'inscription en attente. */ +function promotePending(pending: MockPendingSubmission): VolunteerRead { + const now = touch(); + const volunteer: VolunteerRead = { + id: `v${state.seq++}`, + edition_id: state.edition.id, + first_name: pending.firstName ?? '', + last_name: pending.lastName ?? '', + email: pending.email, + phone: pending.phone, + comment: pending.comment, + artist_ids: filterKnownIds(state.artists, pending.artistIds), + wished_position_ids: filterKnownIds(state.positions, pending.positionIds), + wished_companion_ids: [], + has_photo: pending.photo !== null, + companion_wish_note: pending.companionWishNote, + self_declared_available: pending.available, + willing_to_pay_fee: true, + fee_payment_label: pending.paymentLabel, + fee_amount: pending.paymentAmount, + mecenat_address: pending.mecenatAddress, + mecenat_city: pending.mecenatCity, + mecenat_postal_code: pending.mecenatPostalCode, + mecenat_country: pending.mecenatCountry, + fee_payment_status: 'PAID', + fee_paid_at: now, + created_at: now, + updated_at: now, + }; + if (pending.photo) state.photos.set(volunteer.id, pending.photo); + state.volunteers.push(volunteer); + state.availability.set(volunteer.id, []); + + pending.promotedVolunteerId = volunteer.id; + pending.status = 'PAID'; + pending.firstName = null; + pending.lastName = null; + pending.email = null; + pending.phone = null; + pending.comment = null; + pending.companionWishNote = null; + pending.photo = null; + pending.mecenatAddress = null; + pending.mecenatCity = null; + pending.mecenatPostalCode = null; + pending.mecenatCountry = null; + return volunteer; +} + +/** Simule la création (ou relance) d'un checkout SumUp — pas de vrai appel + * réseau en mode démonstration, voir `app/api/forms.py:_create_pending_checkout`. */ +function createPendingCheckout(pending: MockPendingSubmission): string { + pending.checkoutId = `co-mock-${state.seq++}`; + pending.status = 'PENDING'; + pending.pollCount = 0; + return `https://checkout.sumup.com/pay/mock-${pending.checkoutId}`; +} + /** Vœux de compagnon : comme `resolveWishes`, plus le rejet de soi-même. */ function resolveCompanionWishes(ids: string[], selfId?: string): string[] { const resolved = resolveWishes(state.volunteers, ids, 'volunteer.unknown'); @@ -872,6 +950,10 @@ export function createMockApi(): PlanningApi { willing_to_pay_fee: false, fee_payment_label: null, fee_amount: null, + mecenat_address: null, + mecenat_city: null, + mecenat_postal_code: null, + mecenat_country: null, fee_payment_status: null, fee_paid_at: null, created_at: now, @@ -935,6 +1017,7 @@ export function createMockApi(): PlanningApi { if (!dataUrl) return null; return (await fetch(dataUrl)).blob(); }, + exportXlsx: () => delay(MOCK_XLSX_BLOB()), planning: (_edition, id) => { if (!state.volunteers.some((v) => v.id === id)) return notFound(); const entries = state.assignments @@ -1211,6 +1294,10 @@ export function createMockApi(): PlanningApi { willing_to_pay_fee: false, fee_payment_label: null, fee_amount: null, + mecenat_address: null, + mecenat_city: null, + mecenat_postal_code: null, + mecenat_country: null, fee_payment_status: null, fee_paid_at: null, created_at: now, @@ -1545,6 +1632,7 @@ export function createMockApi(): PlanningApi { id: o.id, label: o.label, amount: o.amount, + requires_mecenat_info: o.requires_mecenat_info, })), }); }, @@ -1563,6 +1651,7 @@ export function createMockApi(): PlanningApi { label, amount: body.amount, order: body.order ?? 0, + requires_mecenat_info: body.requires_mecenat_info ?? false, created_at: now, updated_at: now, }; @@ -1585,6 +1674,9 @@ export function createMockApi(): PlanningApi { label, ...(body.amount !== undefined ? { amount: body.amount } : {}), ...(body.order !== undefined ? { order: body.order } : {}), + ...(body.requires_mecenat_info !== undefined + ? { requires_mecenat_info: body.requires_mecenat_info } + : {}), updated_at: touch(), }); return delay(option); @@ -1604,8 +1696,18 @@ export function createMockApi(): PlanningApi { } const fields = new Set(state.formTemplate.fields); + const email = body.email?.trim() || null; + const phone = body.phone?.trim() || null; + const available = Boolean(body.available); + if (fields.has('EMAIL') && !email) throw new ApiError(422, 'import.row.missing_required_field'); + if (fields.has('PHONE') && !phone) throw new ApiError(422, 'import.row.missing_required_field'); + if (fields.has('AVAILABLE') && !available) { + throw new ApiError(422, 'import.row.missing_required_field'); + } + const positionIds = fields.has('POSITION_WISHES') ? (body.position_wish_ids ?? []) : []; if (positionIds.length > 2) throw new ApiError(422, 'form.too_many_position_wishes'); + if (positionIds.length) resolveWishes(state.positions, positionIds, 'position.unknown'); const artistIds = fields.has('ARTIST_WISHES') ? (body.artist_wish_ids ?? []) : []; const artistDates = artistIds.map( (id) => state.artists.find((a) => a.id === id)?.performance_date, @@ -1613,6 +1715,7 @@ export function createMockApi(): PlanningApi { if (new Set(artistDates).size !== artistDates.length) { throw new ApiError(422, 'form.too_many_artists_same_day'); } + if (artistIds.length) resolveWishes(state.artists, artistIds, 'artist.unknown'); let paymentOption: FormPaymentOptionRead | null = null; if (fields.has('WILLING_TO_PAY_FEE') && body.payment_option_id) { @@ -1620,53 +1723,157 @@ export function createMockApi(): PlanningApi { if (!paymentOption) throw new ApiError(422, 'form.unknown_payment_option'); } - const now = touch(); - const volunteer: VolunteerRead = { - id: `v${state.seq++}`, - edition_id: state.edition.id, - first_name: titleCase(body.first_name.trim()), - last_name: titleCase(body.last_name.trim()), - email: fields.has('EMAIL') ? body.email?.trim() || null : null, - phone: fields.has('PHONE') ? body.phone?.trim() || null : null, - comment: fields.has('COMMENT') ? body.comment?.trim() || null : null, - artist_ids: resolveWishes(state.artists, artistIds, 'artist.unknown'), - wished_position_ids: resolveWishes(state.positions, positionIds, 'position.unknown'), - wished_companion_ids: [], - has_photo: false, - companion_wish_note: fields.has('COMPANION_WISH') - ? body.companion_wish_note?.trim() || null - : null, - self_declared_available: fields.has('AVAILABLE') ? Boolean(body.available) : false, - willing_to_pay_fee: fields.has('WILLING_TO_PAY_FEE') - ? Boolean(body.willing_to_pay_fee) || paymentOption !== null - : false, - fee_payment_label: null, - fee_amount: null, - fee_payment_status: null, - fee_paid_at: null, - created_at: now, - updated_at: now, + const comment = fields.has('COMMENT') ? body.comment?.trim() || null : null; + const companionWishNote = fields.has('COMPANION_WISH') + ? body.companion_wish_note?.trim() || null + : null; + const photo = + fields.has('PHOTO') && body.photo ? await fileToDataUrl(body.photo) : null; + + if (paymentOption === null) { + // Pas de paiement en ligne (case désactivée, ou « je paierai sur + // place ») : rien à attendre, le bénévole est créé tout de suite. + const now = touch(); + const volunteer: VolunteerRead = { + id: `v${state.seq++}`, + edition_id: state.edition.id, + first_name: titleCase(body.first_name.trim()), + last_name: titleCase(body.last_name.trim()), + email: fields.has('EMAIL') ? email : null, + phone: fields.has('PHONE') ? phone : null, + comment, + artist_ids: resolveWishes(state.artists, artistIds, 'artist.unknown'), + wished_position_ids: resolveWishes(state.positions, positionIds, 'position.unknown'), + wished_companion_ids: [], + has_photo: photo !== null, + companion_wish_note: companionWishNote, + self_declared_available: fields.has('AVAILABLE') ? available : false, + willing_to_pay_fee: fields.has('WILLING_TO_PAY_FEE'), + fee_payment_label: null, + fee_amount: null, + mecenat_address: null, + mecenat_city: null, + mecenat_postal_code: null, + mecenat_country: null, + fee_payment_status: null, + fee_paid_at: null, + created_at: now, + updated_at: now, + }; + if (photo) state.photos.set(volunteer.id, photo); + state.volunteers.push(volunteer); + state.availability.set(volunteer.id, []); + return delay({ + volunteer_id: volunteer.id, + pending_token: null, + requires_mecenat_info: false, + sumup_checkout_url: null, + }); + } + + // Paiement en ligne choisi : rien en base tant que le paiement n'est + // pas confirmé — voir `PendingFormSubmission` côté back-end. + const pending: MockPendingSubmission = { + id: `pend${state.seq++}`, + editionId: state.edition.id, + firstName: titleCase(body.first_name.trim()), + lastName: titleCase(body.last_name.trim()), + email: fields.has('EMAIL') ? email : null, + phone: fields.has('PHONE') ? phone : null, + comment, + companionWishNote, + available: fields.has('AVAILABLE') ? available : false, + positionIds, + artistIds, + photo, + paymentLabel: paymentOption.label, + paymentAmount: paymentOption.amount, + requiresMecenatInfo: paymentOption.requires_mecenat_info, + mecenatAddress: null, + mecenatCity: null, + mecenatPostalCode: null, + mecenatCountry: null, + checkoutId: null, + status: null, + promotedVolunteerId: null, + pollCount: 0, }; + state.pendingSubmissions.set(pending.id, pending); - if (fields.has('PHOTO') && body.photo) { - volunteer.has_photo = true; - state.photos.set(volunteer.id, await fileToDataUrl(body.photo)); + if (pending.requiresMecenatInfo) { + return delay({ + volunteer_id: null, + pending_token: pending.id, + requires_mecenat_info: true, + sumup_checkout_url: null, + }); } - - // Simule la création d'un checkout SumUp (pas de vrai appel réseau - // en mode démonstration) — voir `app/api/forms.py:_create_fee_checkout`. - let checkoutUrl: string | null = null; - if (paymentOption && state.formTemplate.sumupMerchantCode && state.formTemplate.sumupApiKey) { - volunteer.fee_payment_label = paymentOption.label; - volunteer.fee_amount = paymentOption.amount; - volunteer.fee_payment_status = 'PENDING'; - checkoutUrl = `https://checkout.sumup.com/pay/mock-${volunteer.id}`; - } - - state.volunteers.push(volunteer); - state.availability.set(volunteer.id, []); + const checkoutUrl = createPendingCheckout(pending); return delay({ - volunteer_id: volunteer.id, + volunteer_id: null, + pending_token: pending.id, + requires_mecenat_info: false, + sumup_checkout_url: checkoutUrl, + }); + }, + submitMecenatInfo: (_edition, token, body: MecenatInfoSubmit) => { + const pending = state.pendingSubmissions.get(token) ?? notFound(); + if (pending.promotedVolunteerId !== null) { + throw new ApiError(422, 'form.pending_already_paid'); + } + if (!pending.requiresMecenatInfo) { + throw new ApiError(422, 'form.pending_not_awaiting_info'); + } + const address = body.address.trim(); + const city = body.city.trim(); + const postalCode = body.postal_code.trim(); + const country = body.country.trim(); + if (!address || !city || !postalCode || !country) { + throw new ApiError(422, 'import.row.missing_required_field'); + } + pending.mecenatAddress = address; + pending.mecenatCity = city; + pending.mecenatPostalCode = postalCode; + pending.mecenatCountry = country; + const checkoutUrl = createPendingCheckout(pending); + return delay({ + volunteer_id: null, + pending_token: pending.id, + requires_mecenat_info: false, + sumup_checkout_url: checkoutUrl, + }); + }, + getPendingStatus: (_edition, token) => { + const pending = state.pendingSubmissions.get(token) ?? notFound(); + // Simule le délai de confirmation réel (webhook asynchrone) : la + // première vérification reste PENDING, la suivante confirme. + if (pending.promotedVolunteerId === null && pending.checkoutId !== null) { + if (pending.pollCount === 0) { + pending.pollCount += 1; + } else { + promotePending(pending); + } + } + return delay({ + status: pending.promotedVolunteerId !== null ? 'PAID' : (pending.status ?? 'PENDING'), + label: pending.paymentLabel, + amount: pending.paymentAmount, + volunteer_id: pending.promotedVolunteerId, + }); + }, + retryPendingPayment: (_edition, token) => { + const pending = state.pendingSubmissions.get(token) ?? notFound(); + if (pending.promotedVolunteerId !== null) { + throw new ApiError(422, 'form.pending_already_paid'); + } + if (pending.requiresMecenatInfo && pending.mecenatAddress === null) { + throw new ApiError(422, 'form.pending_not_awaiting_info'); + } + const checkoutUrl = createPendingCheckout(pending); + return delay({ + volunteer_id: null, + pending_token: pending.id, + requires_mecenat_info: false, sumup_checkout_url: checkoutUrl, }); }, diff --git a/frontend/src/api/schema.ts b/frontend/src/api/schema.ts index 751166d..75272e1 100644 --- a/frontend/src/api/schema.ts +++ b/frontend/src/api/schema.ts @@ -121,6 +121,12 @@ export interface VolunteerRead { fee_amount: number | null; fee_payment_status: FeePaymentStatus | null; fee_paid_at: 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; + mecenat_city: string | null; + mecenat_postal_code: string | null; + mecenat_country: string | null; created_at: string; updated_at: string; } @@ -606,6 +612,9 @@ export interface FormPaymentOptionRead { label: string; amount: number; order: number; + /** Type « mécénat » : le bénévole passe par une étape d'adresse postale + * avant le paiement (reçu fiscal / cerfa). */ + requires_mecenat_info: boolean; created_at: string; updated_at: string; } @@ -614,12 +623,14 @@ export interface FormPaymentOptionCreate { label: string; amount: number; order?: number; + requires_mecenat_info?: boolean; } export interface FormPaymentOptionUpdate { label?: string; amount?: number; order?: number; + requires_mecenat_info?: boolean; } export interface PublicPositionOption { @@ -639,6 +650,7 @@ export interface PublicPaymentOption { id: string; label: string; amount: number; + requires_mecenat_info: boolean; } /** Tout ce qu'il faut pour afficher le formulaire public — jamais plus. */ @@ -655,12 +667,40 @@ export interface PublicFormConfig { payment_options: PublicPaymentOption[]; } +/** Trois formes possibles, selon le choix de paiement (voir + * `app/api/forms.py:submit_public_form`) : + * - aucun paiement en ligne (case désactivée, ou « je paierai sur place ») : + * `volunteer_id` seul, le bénévole est déjà en base ; + * - paiement en ligne, type « mécénat » : `pending_token` + + * `requires_mecenat_info=true`, rien en base encore — direction l'étape + * d'adresse (`submitMecenatInfo`) ; + * - paiement en ligne, type simple : `pending_token` + `sumup_checkout_url`, + * direction la page de paiement SumUp. */ export interface FormSubmissionResult { - volunteer_id: string; - /** Lien de paiement hébergé SumUp, si un checkout a pu être créé. */ + volunteer_id: string | null; + pending_token: string | null; + requires_mecenat_info: boolean; sumup_checkout_url: string | null; } +/** Étape d'adresse postale d'un type de paiement « mécénat » — tous + * obligatoires, nécessaires à l'édition d'un reçu fiscal (cerfa). */ +export interface MecenatInfoSubmit { + address: string; + city: string; + postal_code: string; + country: string; +} + +/** Réponse de la vérification en direct du paiement (page de confirmation) — + * interrogée jusqu'à ce que le statut ne soit plus `PENDING`. */ +export interface PendingSubmissionStatus { + status: FeePaymentStatus; + label: string; + amount: number; + volunteer_id: string | null; +} + /** Champs du formulaire public — envoyés en `multipart/form-data` (photo * possible). `position_wish_ids`/`artist_wish_ids` : JSON stringifié * (`'["id1","id2"]'`), pas de tableau natif possible en multipart. */ @@ -673,9 +713,8 @@ export interface PublicFormSubmission { comment?: string; companion_wish_note?: string; available?: boolean; - willing_to_pay_fee?: boolean; - /** Type de paiement choisi (voir `PublicPaymentOption`) — `undefined` = - * aucun (le bénévole ne participe pas à la cotisation). */ + /** Type de paiement choisi (voir `PublicPaymentOption`) — absent = « je + * paierai sur place » (aucun paiement en ligne). */ payment_option_id?: string; position_wish_ids?: string[]; artist_wish_ids?: string[]; diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 5682208..8584e5b 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -37,6 +37,8 @@ import type { LoginRequest, MagicLinkRequest, MatrixResponse, + MecenatInfoSubmit, + PendingSubmissionStatus, PlanningNotificationRequest, PlanningNotificationResult, PositionCreate, @@ -89,6 +91,8 @@ export interface PlanningApi { planning(editionId: string, id: string): Promise; /** Photo transmise via le formulaire public, `null` si absente. */ photoBlob(editionId: string, id: string): Promise; + /** Export Excel de la liste des bénévoles (COORDINATION). */ + exportXlsx(editionId: string): Promise; }; accounts: { /** Comptes de connexion (mot de passe ou lien magique) de l'édition. */ @@ -187,6 +191,15 @@ export interface PlanningApi { ): Promise; remove(editionId: string, id: string): Promise; }; + /** Inscription en attente de paiement en ligne — publics, aucune + * authentification (voir CLAUDE.md § 2, Paiement de la cotisation). */ + submitMecenatInfo( + editionId: string, + token: string, + body: MecenatInfoSubmit, + ): Promise; + getPendingStatus(editionId: string, token: string): Promise; + retryPendingPayment(editionId: string, token: string): Promise; }; positionSheets: { /** Feuilles de poste PDF (une page par poste et par jour). */ diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json index 27140e8..dcef771 100644 --- a/frontend/src/i18n/locales/fr.json +++ b/frontend/src/i18n/locales/fr.json @@ -221,6 +221,8 @@ "config.rules.roleVolunteerDesc": "Ses seules données, lien magique par email", "config.vol.addButton": "AJOUTER MANUELLEMENT", "config.vol.importButton2": "IMPORTER UN FICHIER", + "config.vol.exportButton": "EXPORTER EN EXCEL", + "config.vol.exporting": "EXPORT…", "config.vol.search": "Rechercher un nom, un email…", "config.vol.emptyList": "Aucun bénévole pour l'instant. Ajoutez-en un ou importez un fichier.", "config.vol.noMatch": "Aucun bénévole ne correspond à la recherche.", @@ -385,6 +387,7 @@ "config.vol.feeStatus.paid": "Cotisation payée ✓", "config.vol.feeStatus.failed": "Cotisation : paiement échoué", "config.vol.feeStatus.expired": "Cotisation : lien de paiement expiré", + "config.vol.mecenatAddress": "Adresse (cerfa)", "form.field.photo": "Une magnifique photo de toi :", "form.field.comment": "Commentaire", "form.link.title": "Lien du formulaire", @@ -415,6 +418,8 @@ "form.customize.paymentOptions.title": "Types de paiement", "form.customize.paymentOptions.label": "Libellé (ex. Cotisation classique, Mécénat…)", "form.customize.paymentOptions.add": "Ajouter", + "form.customize.paymentOptions.mecenat": "Mécénat", + "form.customize.paymentOptions.mecenat.help": "Avant de payer, le bénévole passe par une étape d'adresse postale (reçu fiscal / cerfa).", "publicForm.title": "Inscription bénévole", "publicForm.positionWishes.count": "{count} / {max}", @@ -422,11 +427,9 @@ "publicForm.field.available": "Je suis bénévole les deux jours (vendredi 30 et samedi 31 oct.)", "publicForm.field.willingToPayFee": "Je participe à la cotisation bénévole", "publicForm.field.paymentOption": "Cotisation", - "publicForm.paymentOption.none": "Je ne participe pas", + "publicForm.paymentOption.onSite": "Je paierai sur place", "publicForm.photo.squareHint": "Recadrée automatiquement en carré.", "publicForm.sumup.afterSubmitHint": "Un lien de paiement en ligne (SumUp) te sera proposé juste après l'envoi de ton inscription.", - "publicForm.sumup.hint": "Le paiement se fait en ligne, via SumUp.", - "publicForm.sumup.cta": "Payer ma cotisation ↗", "publicForm.captcha.disabled": "(Vérification anti-robot non configurée sur cette instance.)", "publicForm.submitting": "Envoi…", "publicForm.submit": "Envoyer mon inscription", @@ -434,6 +437,25 @@ "publicForm.success.body": "Merci ! Ton inscription a bien été enregistrée. La coordination du festival prendra contact avec toi.", "publicForm.closed.title": "Inscriptions fermées", "publicForm.closed.body": "Les inscriptions bénévoles ne sont pas ouvertes pour le moment. Reviens un peu plus tard !", + + "publicForm.mecenat.title": "Reçu fiscal (mécénat)", + "publicForm.mecenat.hint": "Ces informations servent uniquement à établir ton reçu fiscal (cerfa) une fois le paiement effectué.", + "publicForm.mecenat.afterSubmitHint": "Une étape supplémentaire (adresse) te sera proposée juste après l'envoi de ton inscription, pour établir ton reçu fiscal.", + "publicForm.mecenat.address": "Adresse", + "publicForm.mecenat.city": "Ville", + "publicForm.mecenat.postalCode": "Code postal", + "publicForm.mecenat.country": "Pays", + "publicForm.mecenat.submit": "Continuer vers le paiement", + + "publicForm.retry.title": "Paiement indisponible", + "publicForm.retry.body": "Le paiement en ligne n'a pas pu être initié pour le moment. Tes informations sont conservées : réessaie dans quelques instants.", + "publicForm.retry.cta": "Réessayer le paiement", + "publicForm.retry.stillUnavailable": "Toujours indisponible pour le moment, réessaie un peu plus tard.", + + "publicForm.confirmation.title": "Confirmation du paiement", + "publicForm.confirmation.pending": "Vérification du paiement en cours…", + "publicForm.confirmation.paid": "Merci ! Cotisation « {label} » de {amount} € payée. Ton inscription est enregistrée.", + "publicForm.confirmation.failed": "Le paiement n'a pas abouti (refusé ou expiré).", "form.closed": "Les inscriptions sont actuellement fermées.", "form.captcha_failed": "Vérification anti-robot invalide, réessaie.", "form.invalid_payload": "Données du formulaire invalides.", @@ -445,6 +467,8 @@ "form.invalid_sumup_amount": "Le montant doit être supérieur à 0.", "form.unknown_payment_option": "Ce type de paiement n'existe pas pour cette édition.", "form.duplicate_payment_option_label": "Un type de paiement porte déjà ce libellé.", + "form.pending_already_paid": "Cette inscription est déjà payée.", + "form.pending_not_awaiting_info": "Cette inscription n'attend pas ces informations.", "form.too_many_position_wishes": "Deux vœux de poste au maximum.", "form.too_many_artists_same_day": "Un seul artiste souhaité par soir de festival.", "position_sheets.no_positions": "Aucun poste ne correspond à cette sélection.", diff --git a/frontend/src/pages/FormBuilderPage.tsx b/frontend/src/pages/FormBuilderPage.tsx index 2340c12..20973fa 100644 --- a/frontend/src/pages/FormBuilderPage.tsx +++ b/frontend/src/pages/FormBuilderPage.tsx @@ -331,6 +331,7 @@ function PaymentOptionsForm() { const remove = useDeleteFormPaymentOption(); const [label, setLabel] = useState(''); const [amount, setAmount] = useState(''); + const [requiresMecenatInfo, setRequiresMecenatInfo] = useState(false); if (options.isLoading) return
…
; if (options.error) return ; @@ -363,6 +364,20 @@ function PaymentOptionsForm() { className="w-[90px] border-2 border-ink bg-paper px-1 py-1 font-mono text-[11px]" /> € +
+ + {exportVolunteers.isError ? ( + + ) : null}
{total}