diff --git a/CLAUDE.md b/CLAUDE.md index a9ac4fc..6663271 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -80,9 +80,11 @@ les tables sauf les tables d'association. forcément encore en base au moment de l'inscription, donc pas de lien direct vers un `VolunteerCompanionWish` ; la coordination la résout à la main depuis la fiche une fois les deux bénévoles inscrits), - `self_declared_available`/`willing_to_pay_fee` (cases à cocher **purement - informatives**, sans lien avec `Availability` ni aucun workflow de - paiement). + `self_declared_available` (case à cocher **purement informative**, sans + lien avec `Availability`) et `willing_to_pay_fee` (« je participe à la + cotisation » — déclenche, lui, un vrai paiement : voir `fee_payment_label`/ + `fee_amount`/`fee_checkout_id`/`fee_payment_status`/`fee_paid_at` et le + paiement SumUp plus bas). - **`Artist`** : programmation de l'édition (`name`, `performance_date`, `start_time`, `end_time` — `end_time <= start_time` ⇒ le passage finit le lendemain). Unique (`edition_id`, `name`, `performance_date`). La @@ -194,7 +196,22 @@ les tables sauf les tables d'association. affectation n'apparaît pas (rien à imprimer). - **`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. + nouvelles soumissions sans effacer la configuration. `sumup_merchant_code`/ + `sumup_api_key` sont les identifiants du compte marchand SumUp pour le + paiement de la cotisation (`app/sumup/`) — `sumup_api_key` est un secret, + jamais renvoyé par l'API (`FormTemplateRead.sumup_api_key_set: bool` à la + 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`). - **`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 @@ -205,14 +222,45 @@ les tables sauf les tables d'association. 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` et `POST .../form/public/submit` sont les - **deux seuls endpoints de toute l'API sans authentification** — protégés - par hCaptcha (`app/forms/captcha.py`, `HCAPTCHA_SECRET_KEY` ; vérification + `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 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. + 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). Conventions techniques : @@ -537,6 +585,7 @@ l'étape 21.)* │ │ ├── badges/ # génération PDF des planches (pdf.py, ReportLab) │ │ ├── sheets/ # génération PDF des feuilles de poste (pdf.py) │ │ ├── forms/ # hCaptcha (captcha.py) — voir api/forms.py +│ │ ├── sumup/ # client API SumUp (client.py) — paiement de la cotisation │ │ ├── bootstrap.py # instance vierge : édition + 1er compte │ │ ├── seed.py # jeu de démonstration (python -m app.seed) │ │ ├── i18n/ @@ -651,6 +700,7 @@ 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 | **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/forms.py b/backend/app/api/forms.py index d0ae4df..4d34381 100644 --- a/backend/app/api/forms.py +++ b/backend/app/api/forms.py @@ -2,11 +2,12 @@ Deux familles d'endpoints sous le même routeur : - configuration (COORDINATION) : quels champs sont activés, formulaire - ouvert ou fermé ; -- publics (**aucune authentification** — les seuls endpoints de toute - l'API dans ce cas) : consultation de la configuration et soumission, - protégés par hCaptcha (`app/forms/captcha.py`) et un filet de sécurité - global (`app/auth/rate_limit.py:form_submission_limiter`). + ouvert ou fermé, identifiants SumUp de paiement de la cotisation ; +- publics (**aucune authentification**) : consultation de la configuration + et soumission (protégées par hCaptcha, `app/forms/captcha.py`, et un filet + de sécurité global, `app/auth/rate_limit.py:form_submission_limiter`), et + le webhook SumUp (`/public/sumup-webhook`, appelé par SumUp lui-même — pas + de hCaptcha possible serveur à serveur, voir `sumup_webhook` plus bas). Nom et prénom sont toujours collectés (indispensables à la création du bénévole) : ils ne font pas partie des champs activables, contrairement aux @@ -20,6 +21,7 @@ coordination, faute de savoir si la personne visée est déjà inscrite (voir CLAUDE.md § 2, `Volunteer`).""" import json +import logging import uuid from fastapi import ( @@ -33,33 +35,46 @@ from fastapi import ( status, ) from sqlalchemy import delete +from sqlalchemy.exc import IntegrityError from sqlmodel import Session, select -from app.api._common import set_artists, set_wished_positions +from app.api._common import owned_or_404, set_artists, set_wished_positions from app.api.deps import get_edition from app.api.schemas import ( FormFieldsSet, + FormPaymentOptionCreate, + FormPaymentOptionRead, + FormPaymentOptionUpdate, FormSubmissionResult, FormTemplateRead, FormTemplateUpdate, PublicArtistOption, PublicFormConfig, + PublicPaymentOption, PublicPositionOption, + SumUpWebhookPayload, ) 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.forms import verify_hcaptcha from app.models import ( Account, Artist, Edition, + FeePaymentStatus, FormField, FormFieldType, + FormPaymentOption, FormTemplate, Position, Volunteer, + local_now, ) +from app.sumup import create_checkout, get_checkout + +logger = logging.getLogger("app.forms") router = APIRouter(prefix="/editions/{edition_id}/form", tags=["form"]) @@ -95,6 +110,20 @@ def _enabled_fields(session: Session, edition_id: uuid.UUID) -> list[FormFieldTy ) +def _payment_options(session: Session, edition_id: uuid.UUID) -> list[FormPaymentOption]: + return list( + session.exec( + select(FormPaymentOption) + .where(FormPaymentOption.edition_id == edition_id) + .order_by(FormPaymentOption.order, FormPaymentOption.label) + ) + ) + + +def _sumup_enabled(template: FormTemplate, options: list[FormPaymentOption]) -> bool: + return bool(template.sumup_merchant_code and template.sumup_api_key and options) + + def _template_read(template: FormTemplate, fields: list[FormFieldType]) -> FormTemplateRead: return FormTemplateRead( edition_id=template.edition_id, @@ -102,7 +131,8 @@ def _template_read(template: FormTemplate, fields: list[FormFieldType]) -> FormT fields=fields, has_logo=template.logo is not None, description=template.description, - yapla_url=template.yapla_url, + sumup_merchant_code=template.sumup_merchant_code, + sumup_api_key_set=bool(template.sumup_api_key), ) @@ -140,11 +170,8 @@ def update_form( ) -> FormTemplateRead: template = _get_or_create_template(session, edition.id) changes = payload.model_dump(exclude_unset=True) - if changes.get("yapla_url"): - if not changes["yapla_url"].startswith(("http://", "https://")): - raise HTTPException( - status_code=422, detail={"code": "form.invalid_yapla_url"} - ) + if changes.get("sumup_api_key") == "": + changes["sumup_api_key"] = None for field, value in changes.items(): setattr(template, field, value) session.add(template) @@ -152,6 +179,96 @@ def update_form( return _template_read(template, _enabled_fields(session, edition.id)) +# --------------------------------------------------------------------------- # +# Types de paiement (COORDINATION) — voir `FormPaymentOption` # +# --------------------------------------------------------------------------- # +@router.get("/payment-options", response_model=list[FormPaymentOptionRead]) +def list_payment_options( + edition: Edition = Depends(get_edition), + _guard: Account = Depends(require_coordinator), + session: Session = Depends(get_session), +) -> list[FormPaymentOption]: + return _payment_options(session, edition.id) + + +@router.post( + "/payment-options", + response_model=FormPaymentOptionRead, + status_code=status.HTTP_201_CREATED, +) +def create_payment_option( + payload: FormPaymentOptionCreate, + edition: Edition = Depends(get_edition), + _guard: Account = Depends(require_coordinator), + session: Session = Depends(get_session), +) -> FormPaymentOption: + if payload.amount <= 0: + raise HTTPException( + status_code=422, detail={"code": "form.invalid_sumup_amount"} + ) + option = FormPaymentOption( + edition_id=edition.id, + label=payload.label.strip(), + amount=payload.amount, + order=payload.order, + ) + session.add(option) + try: + session.commit() + except IntegrityError as exc: + session.rollback() + raise HTTPException( + status_code=409, detail={"code": "form.duplicate_payment_option_label"} + ) from exc + session.refresh(option) + return option + + +@router.patch("/payment-options/{option_id}", response_model=FormPaymentOptionRead) +def update_payment_option( + option_id: uuid.UUID, + payload: FormPaymentOptionUpdate, + edition: Edition = Depends(get_edition), + _guard: Account = Depends(require_coordinator), + session: Session = Depends(get_session), +) -> FormPaymentOption: + option = owned_or_404(session.get(FormPaymentOption, option_id), edition.id) + changes = payload.model_dump(exclude_unset=True) + if changes.get("amount") is not None and changes["amount"] <= 0: + raise HTTPException( + status_code=422, detail={"code": "form.invalid_sumup_amount"} + ) + if changes.get("label") is not None: + changes["label"] = changes["label"].strip() + for field, value in changes.items(): + setattr(option, field, value) + session.add(option) + try: + session.commit() + except IntegrityError as exc: + session.rollback() + raise HTTPException( + status_code=409, detail={"code": "form.duplicate_payment_option_label"} + ) from exc + session.refresh(option) + return option + + +@router.delete("/payment-options/{option_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_payment_option( + option_id: uuid.UUID, + edition: Edition = Depends(get_edition), + _guard: Account = Depends(require_coordinator), + session: Session = Depends(get_session), +) -> Response: + option = owned_or_404(session.get(FormPaymentOption, option_id), edition.id) + # Les bénévoles ayant déjà choisi cette option gardent leur copie figée + # (`Volunteer.fee_payment_label`/`fee_amount`) — rien à faire côté eux. + session.delete(option) + session.commit() + return Response(status_code=status.HTTP_204_NO_CONTENT) + + async def _read_logo(file: UploadFile) -> tuple[bytes, str]: content_type = file.content_type or "" if content_type not in _ALLOWED_PHOTO_TYPES: @@ -233,6 +350,7 @@ def get_public_form( ) ] + options = _payment_options(session, edition.id) return PublicFormConfig( is_open=template.is_open, fields=fields, @@ -240,7 +358,10 @@ def get_public_form( artists=artists, has_logo=template.logo is not None, description=template.description, - yapla_url=template.yapla_url, + sumup_enabled=_sumup_enabled(template, options), + payment_options=[ + PublicPaymentOption(id=o.id, label=o.label, amount=o.amount) for o in options + ], ) @@ -295,6 +416,7 @@ async def submit_public_form( 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), photo: UploadFile | None = File(None), @@ -335,6 +457,20 @@ async def submit_public_form( if artist_ids: _check_one_artist_wish_per_day(session, edition.id, artist_ids) + payment_option: FormPaymentOption | None = None + if FormFieldType.WILLING_TO_PAY_FEE in fields and payment_option_id: + try: + option_uuid = uuid.UUID(payment_option_id) + except ValueError: + option_uuid = None + payment_option = ( + session.get(FormPaymentOption, option_uuid) if option_uuid else None + ) + if payment_option is None or payment_option.edition_id != edition.id: + raise HTTPException( + status_code=422, detail={"code": "form.unknown_payment_option"} + ) + volunteer = Volunteer( edition_id=edition.id, first_name=first_name.strip().title(), @@ -352,8 +488,13 @@ async def submit_public_form( self_declared_available=( available if FormFieldType.AVAILABLE in fields else False ), + # 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 if FormFieldType.WILLING_TO_PAY_FEE in fields else False + (willing_to_pay_fee or payment_option is not None) + if FormFieldType.WILLING_TO_PAY_FEE in fields + else False ), ) @@ -382,4 +523,89 @@ async def submit_public_form( session.commit() session.refresh(volunteer) - return FormSubmissionResult(volunteer_id=volunteer.id) + + 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 + ) + + return FormSubmissionResult(volunteer_id=volunteer.id, sumup_checkout_url=checkout_url) + + +def _create_fee_checkout( + 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 + + 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) + session.commit() + return checkout.hosted_checkout_url if checkout is not None else None + + +@router.post("/public/sumup-webhook", status_code=status.HTTP_200_OK) +def sumup_webhook( + payload: SumUpWebhookPayload, + edition: Edition = Depends(get_edition), + session: Session = Depends(get_session), +) -> Response: + """Appelé par SumUp (aucune authentification possible côté SumUp) à + chaque changement de statut d'un checkout. Le payload ne donne qu'un + identifiant : jamais fait confiance sans relire l'état réel via l'API + (voir `app/sumup/client.py:get_checkout`). Répond toujours `200`, y + compris sur un événement inconnu — SumUp réessaie sinon inutilement.""" + 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 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.") + return Response(status_code=status.HTTP_200_OK) diff --git a/backend/app/api/schemas.py b/backend/app/api/schemas.py index d987453..c01c6f7 100644 --- a/backend/app/api/schemas.py +++ b/backend/app/api/schemas.py @@ -9,6 +9,7 @@ from app.models import ( AssignmentSource, BadgeAlign, BadgeFieldType, + FeePaymentStatus, FormFieldType, PositionCategory, Role, @@ -92,6 +93,15 @@ class VolunteerRead(ApiModel): companion_wish_note: str | None self_declared_available: bool willing_to_pay_fee: bool + # Paiement de la cotisation via SumUp (voir CLAUDE.md § 2, `Volunteer`) — + # `fee_payment_label`/`fee_amount` : copie figée du type de paiement + # choisi (pas la config actuelle, qui peut avoir changé depuis). + # `fee_payment_status` reste `None` tant qu'aucun checkout n'a jamais été + # créé pour ce bénévole. + fee_payment_label: str | None + fee_amount: float | None + fee_payment_status: FeePaymentStatus | None + fee_paid_at: dt.datetime | None created_at: dt.datetime updated_at: dt.datetime @@ -493,16 +503,43 @@ class FormTemplateRead(ApiModel): fields: list[FormFieldType] has_logo: bool description: str | None - yapla_url: str | None + sumup_merchant_code: str | None + # `sumup_api_key` (secret) n'est jamais renvoyé — seulement s'il est + # défini, comme `has_logo` pour le logo. + sumup_api_key_set: bool class FormTemplateUpdate(ApiModel): """`None`/absent = inchangé pour chaque champ (`exclude_unset`) — permet - de changer `is_open` sans toucher `description`/`yapla_url`, etc.""" + de changer `is_open` sans toucher `description`/`sumup_*`, etc. Chaîne + vide sur `sumup_api_key` = efface le secret enregistré.""" is_open: bool | None = None description: str | None = None - yapla_url: str | None = None + sumup_merchant_code: str | None = None + sumup_api_key: str | None = None + + +class FormPaymentOptionRead(ApiModel): + id: uuid.UUID + edition_id: uuid.UUID + label: str + amount: float + order: int + created_at: dt.datetime + updated_at: dt.datetime + + +class FormPaymentOptionCreate(ApiModel): + label: str + amount: float + order: int = 0 + + +class FormPaymentOptionUpdate(ApiModel): + label: str | None = None + amount: float | None = None + order: int | None = None class PublicPositionOption(ApiModel): @@ -516,6 +553,15 @@ class PublicArtistOption(ApiModel): performance_date: dt.date +class PublicPaymentOption(ApiModel): + """Type de paiement proposé au bénévole sur le formulaire public — un + montant n'est pas un secret, contrairement aux identifiants SumUp.""" + + id: uuid.UUID + label: str + amount: float + + class PublicFormConfig(ApiModel): """Tout ce qu'il faut pour afficher le formulaire public — jamais plus : ni les bénévoles déjà inscrits, ni les créneaux/affectations de @@ -527,8 +573,26 @@ class PublicFormConfig(ApiModel): artists: list[PublicArtistOption] has_logo: bool description: str | None - yapla_url: str | None + # `True` si l'édition a configuré SumUp (identifiants marchand + au + # moins un type de paiement) — utilisé par le front-end pour afficher le + # choix de paiement près de `WILLING_TO_PAY_FEE`, sans jamais exposer les + # identifiants eux-mêmes. + sumup_enabled: bool + payment_options: list[PublicPaymentOption] 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). + sumup_checkout_url: str | 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.""" + + event_type: str + id: str diff --git a/backend/app/api/volunteers.py b/backend/app/api/volunteers.py index 0497acd..9081e7c 100644 --- a/backend/app/api/volunteers.py +++ b/backend/app/api/volunteers.py @@ -99,7 +99,9 @@ def _companion_ids(session: Session, volunteer_id: uuid.UUID) -> list[uuid.UUID] def _read(session: Session, volunteer: Volunteer) -> VolunteerRead: return VolunteerRead( - **volunteer.model_dump(exclude={"photo", "photo_content_type"}), + **volunteer.model_dump( + exclude={"photo", "photo_content_type", "fee_checkout_id"} + ), artist_ids=_artist_ids(session, volunteer.id), wished_position_ids=_wished_position_ids(session, volunteer.id), wished_companion_ids=_companion_ids(session, volunteer.id), diff --git a/backend/app/i18n/locales/fr.json b/backend/app/i18n/locales/fr.json index 5118940..00cbdb5 100644 --- a/backend/app/i18n/locales/fr.json +++ b/backend/app/i18n/locales/fr.json @@ -82,7 +82,9 @@ "form.logo_type_not_supported": "Le logo doit être au format PNG ou JPEG.", "form.logo_empty": "Le logo est vide.", "form.logo_too_large": "Le logo est trop volumineux.", - "form.invalid_yapla_url": "Le lien Yapla doit commencer par http:// ou https://.", + "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.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/0016_sumup_payment.py b/backend/app/migrations/versions/0016_sumup_payment.py new file mode 100644 index 0000000..928f5d9 --- /dev/null +++ b/backend/app/migrations/versions/0016_sumup_payment.py @@ -0,0 +1,69 @@ +"""Paiement de la cotisation via l'API SumUp (remplace le lien Yapla) + +Revision ID: 0016_sumup_payment +Revises: 0015_form_yapla_url +Create Date: 2026-09-29 + +`form_template.yapla_url` (simple lien externe statique) est remplacé par un +vrai suivi via l'API SumUp : identifiants marchand (`sumup_merchant_code`, +`sumup_api_key`) et montant (`sumup_fee_amount`) configurés par édition, plus +le statut de paiement par bénévole (`volunteer.fee_checkout_id`, +`fee_payment_status`, `fee_paid_at`), tenu à jour par le webhook SumUp — voir +`app/sumup/` et `app/api/forms.py`. +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "0016_sumup_payment" +down_revision: str | None = "0015_form_yapla_url" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.drop_column("form_template", "yapla_url") + op.add_column( + "form_template", sa.Column("sumup_merchant_code", sa.String(), nullable=True) + ) + op.add_column( + "form_template", sa.Column("sumup_api_key", sa.String(), nullable=True) + ) + op.add_column( + "form_template", sa.Column("sumup_fee_amount", sa.Float(), nullable=True) + ) + + op.add_column( + "volunteer", sa.Column("fee_checkout_id", sa.String(), nullable=True) + ) + op.create_index( + "ix_volunteer_fee_checkout_id", "volunteer", ["fee_checkout_id"] + ) + op.add_column( + "volunteer", + sa.Column( + "fee_payment_status", + sa.Enum( + "PENDING", "PAID", "FAILED", "EXPIRED", + name="feepaymentstatus", native_enum=False, + ), + nullable=True, + ), + ) + op.add_column( + "volunteer", sa.Column("fee_paid_at", sa.DateTime(), nullable=True) + ) + + +def downgrade() -> None: + op.drop_column("volunteer", "fee_paid_at") + op.drop_column("volunteer", "fee_payment_status") + op.drop_index("ix_volunteer_fee_checkout_id", table_name="volunteer") + op.drop_column("volunteer", "fee_checkout_id") + + op.drop_column("form_template", "sumup_fee_amount") + op.drop_column("form_template", "sumup_api_key") + op.drop_column("form_template", "sumup_merchant_code") + op.add_column("form_template", sa.Column("yapla_url", sa.String(), nullable=True)) diff --git a/backend/app/migrations/versions/0017_form_payment_options.py b/backend/app/migrations/versions/0017_form_payment_options.py new file mode 100644 index 0000000..c488054 --- /dev/null +++ b/backend/app/migrations/versions/0017_form_payment_options.py @@ -0,0 +1,65 @@ +"""Plusieurs types de paiement de la cotisation (SumUp) + +Revision ID: 0017_form_payment_options +Revises: 0016_sumup_payment +Create Date: 2026-09-29 + +`form_template.sumup_fee_amount` (un montant unique) est remplacé par une +liste libre de types de paiement (`form_payment_option` : libellé + montant, +configurés par la coordination, comme les créneaux types `DayPart`). Le +bénévole en choisit au plus un à l'inscription ; le libellé et le montant +choisis sont recopiés sur `volunteer` (`fee_payment_label`/`fee_amount`) au +moment de la création du checkout — voir `app/api/forms.py`. +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "0017_form_payment_options" +down_revision: str | None = "0016_sumup_payment" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "form_payment_option", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("edition_id", sa.Uuid(), nullable=False), + sa.Column("label", sa.String(), nullable=False), + sa.Column("amount", sa.Float(), nullable=False), + sa.Column("order", sa.Integer(), server_default="0", nullable=False), + 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.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "edition_id", "label", name="uq_form_payment_option_edition_label" + ), + ) + op.create_index( + "ix_form_payment_option_edition_id", "form_payment_option", ["edition_id"] + ) + + op.drop_column("form_template", "sumup_fee_amount") + + op.add_column( + "volunteer", sa.Column("fee_payment_label", sa.String(), nullable=True) + ) + op.add_column("volunteer", sa.Column("fee_amount", sa.Float(), nullable=True)) + + +def downgrade() -> None: + op.drop_column("volunteer", "fee_amount") + op.drop_column("volunteer", "fee_payment_label") + + op.add_column( + "form_template", sa.Column("sumup_fee_amount", sa.Float(), nullable=True) + ) + + op.drop_index( + "ix_form_payment_option_edition_id", table_name="form_payment_option" + ) + op.drop_table("form_payment_option") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index c544835..547d0d8 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -15,6 +15,7 @@ from app.models.domain import ( DayPart, Edition, FormField, + FormPaymentOption, FormTemplate, MagicLinkToken, Position, @@ -28,6 +29,7 @@ from app.models.enums import ( AssignmentSource, BadgeAlign, BadgeFieldType, + FeePaymentStatus, FormFieldType, PositionCategory, Role, @@ -45,8 +47,10 @@ __all__ = [ "BadgeTemplate", "DayPart", "Edition", + "FeePaymentStatus", "FormField", "FormFieldType", + "FormPaymentOption", "FormTemplate", "MagicLinkToken", "Position", diff --git a/backend/app/models/domain.py b/backend/app/models/domain.py index 0f05fb1..9102283 100644 --- a/backend/app/models/domain.py +++ b/backend/app/models/domain.py @@ -18,6 +18,7 @@ from app.models.enums import ( AssignmentSource, BadgeAlign, BadgeFieldType, + FeePaymentStatus, FormFieldType, PositionCategory, Role, @@ -192,6 +193,24 @@ class Volunteer(TimestampMixin, table=True): default=False, sa_column=Column(Boolean, nullable=False, server_default="false"), ) + # Paiement de la cotisation via l'API SumUp (`app/sumup/`, voir + # `app/api/forms.py:submit_public_form`/`sumup_webhook`) — créé si le + # bénévole choisit un type de paiement (`FormPaymentOption`) et que + # l'édition a configuré SumUp (`FormTemplate.sumup_*`). `fee_payment_label`/ + # `fee_amount` sont une copie figée du libellé/montant de l'option + # choisie au moment du choix (jamais recalculés si l'option est éditée + # ou supprimée ensuite). `fee_checkout_id` (identifiant SumUp du + # checkout) sert seulement à faire le lien depuis le webhook ; jamais + # exposé (voir `app/api/volunteers.py:_read`). `fee_payment_status` reste + # `NULL` tant qu'aucun checkout n'a été créé. + fee_payment_label: str | None = None + fee_amount: float | None = None + fee_checkout_id: str | None = Field(default=None, index=True) + fee_payment_status: FeePaymentStatus | None = Field( + default=None, + sa_column=Column(SAEnum(FeePaymentStatus, native_enum=False), nullable=True), + ) + fee_paid_at: dt.datetime | None = None wished_artists: list[Artist] = Relationship( back_populates="fans", @@ -467,10 +486,12 @@ class FormTemplate(TimestampMixin, table=True): """Configuration du formulaire public d'une édition. Une ligne par édition (créée à la demande). `is_open` coupe les nouvelles soumissions sans effacer la configuration (les champs activés restent enregistrés). - `logo`/`logo_content_type`, `description` et `yapla_url` habillent la - page publique (`/inscription`) — aucun rapport avec les champs collectés - par `FormField` ; stockage en base comme les autres images de l'appli - (badges, photo de bénévole), pas de volume de fichiers à gérer.""" + `logo`/`logo_content_type` et `description` habillent la page publique + (`/inscription`) — aucun rapport avec les champs collectés par + `FormField` ; stockage en base comme les autres images de l'appli + (badges, photo de bénévole), pas de volume de fichiers à gérer. + `sumup_*` configurent le paiement de la cotisation via l'API SumUp (voir + `app/sumup/`).""" __tablename__ = "form_template" @@ -485,12 +506,45 @@ class FormTemplate(TimestampMixin, table=True): logo: bytes | None = Field(default=None, sa_column=Column(LargeBinary)) logo_content_type: str | None = None description: str | None = None - # Lien vers la page de cotisation Yapla (yapla.com, plateforme externe de - # paiement pour associations) — encart affiché sur `/inscription` près de - # la case « je participe à la cotisation » (`FormFieldType. - # WILLING_TO_PAY_FEE`). Rien n'est vérifié côté appli : la case reste - # une déclaration, le paiement se passe entièrement chez Yapla. - yapla_url: str | None = None + # Paiement de la cotisation via l'API SumUp (`app/sumup/`) : un checkout + # est créé à la soumission si le bénévole choisit un des types de + # paiement configurés (`FormPaymentOption`, voir plus bas) et que ces + # deux identifiants sont renseignés (voir `app/api/forms.py`, + # `sumup_enabled` sur `PublicFormConfig`). `sumup_api_key` n'est jamais + # renvoyé par l'API (voir `FormTemplateRead.sumup_api_key_set`) — c'est + # un secret, au même titre qu'un mot de passe SMTP. + sumup_merchant_code: str | None = None + sumup_api_key: str | None = None + + +class FormPaymentOption(TimestampMixin, table=True): + """Un type de paiement proposé sur le formulaire public (ex. « Cotisation + classique », « Mécénat ») : libellé + montant fixe, configurés librement + par la coordination (autant qu'elle veut, comme les créneaux types + `DayPart`) — voir `app/api/forms.py`. Le bénévole en choisit au plus un + à l'inscription ; le libellé et le montant réellement proposés sont + recopiés sur `Volunteer` au moment de la création du checkout + (`fee_payment_label`/`fee_amount`), pour ne jamais changer + rétroactivement si cette configuration est éditée ou supprimée plus + tard.""" + + __tablename__ = "form_payment_option" + __table_args__ = ( + UniqueConstraint( + "edition_id", "label", name="uq_form_payment_option_edition_label" + ), + ) + + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + edition_id: uuid.UUID = Field( + foreign_key="edition.id", index=True, ondelete="CASCADE" + ) + label: str + amount: float + order: int = Field( + default=0, + sa_column=Column(Integer, nullable=False, server_default="0"), + ) class FormField(SQLModel, table=True): diff --git a/backend/app/models/enums.py b/backend/app/models/enums.py index 46de5c8..dad22f1 100644 --- a/backend/app/models/enums.py +++ b/backend/app/models/enums.py @@ -45,6 +45,18 @@ class BadgeAlign(enum.StrEnum): RIGHT = "RIGHT" +class FeePaymentStatus(enum.StrEnum): + """Statut d'un paiement de cotisation via l'API SumUp (voir `app/sumup/` + et `app/api/forms.py`). Reprend tel quel le statut d'un checkout SumUp — + `NULL` sur `Volunteer.fee_payment_status` tant qu'aucun checkout n'a + jamais été créé pour ce bénévole.""" + + PENDING = "PENDING" + PAID = "PAID" + FAILED = "FAILED" + EXPIRED = "EXPIRED" + + class FormFieldType(enum.StrEnum): """Champ activable sur le formulaire public d'inscription (voir `app/api/forms.py`). Nom/prénom sont toujours collectés, pas au choix de diff --git a/backend/app/sumup/__init__.py b/backend/app/sumup/__init__.py new file mode 100644 index 0000000..5437ef4 --- /dev/null +++ b/backend/app/sumup/__init__.py @@ -0,0 +1,3 @@ +from app.sumup.client import SumUpCheckout, create_checkout, get_checkout + +__all__ = ["SumUpCheckout", "create_checkout", "get_checkout"] diff --git a/backend/app/sumup/client.py b/backend/app/sumup/client.py new file mode 100644 index 0000000..acfe677 --- /dev/null +++ b/backend/app/sumup/client.py @@ -0,0 +1,92 @@ +"""Client de l'API SumUp — paiement de la cotisation (voir CLAUDE.md § 2, +`FormTemplate`, et `app/api/forms.py`). + +`create_checkout`/`get_checkout` sont les points d'injection uniques pour les +tests (remplacés comme `app.forms.captcha.verify_hcaptcha`). Best effort, +comme l'envoi d'emails : toute erreur réseau/HTTP est journalisée (code/ +classe d'exception seulement, jamais de PII) et renvoyée comme `None` — +l'inscription au formulaire public n'échoue jamais pour autant. +""" + +import logging +from dataclasses import dataclass + +import httpx + +logger = logging.getLogger("app.sumup") + +_BASE_URL = "https://api.sumup.com" +_TIMEOUT_SECONDS = 10.0 + + +@dataclass(frozen=True) +class SumUpCheckout: + id: str + status: str # PENDING / PAID / FAILED / EXPIRED (voir FeePaymentStatus) + hosted_checkout_url: str | None + + +def _call(method: str, path: str, api_key: str, json: dict | None = None) -> dict | None: + try: + resp = httpx.request( + method, + f"{_BASE_URL}{path}", + json=json, + headers={"Authorization": f"Bearer {api_key}"}, + timeout=_TIMEOUT_SECONDS, + ) + resp.raise_for_status() + return resp.json() + except httpx.HTTPError as exc: + logger.warning( + "Échec de l'appel API SumUp (%s %s) : %s", method, path, exc.__class__.__name__ + ) + return None + + +def _to_checkout(data: dict) -> SumUpCheckout: + return SumUpCheckout( + id=data["id"], + status=data["status"], + hosted_checkout_url=data.get("hosted_checkout_url"), + ) + + +def create_checkout( + *, + api_key: str, + merchant_code: str, + amount: float, + currency: str, + checkout_reference: str, + description: str, + return_url: str, + redirect_url: str, +) -> SumUpCheckout | None: + """Crée un checkout hébergé (`hosted_checkout.enabled=true`) : la page de + paiement SumUp est à `hosted_checkout_url`. `None` en cas d'échec (réseau, + identifiants invalides…).""" + data = _call( + "POST", + "/v0.1/checkouts", + api_key, + json={ + "checkout_reference": checkout_reference, + "amount": amount, + "currency": currency, + "merchant_code": merchant_code, + "description": description, + "return_url": return_url, + "redirect_url": redirect_url, + "hosted_checkout": {"enabled": True}, + }, + ) + return _to_checkout(data) if data is not None else None + + +def get_checkout(*, api_key: str, checkout_id: str) -> SumUpCheckout | None: + """Relit l'état réel d'un checkout — un webhook SumUp ne donne qu'un + identifiant, jamais un statut de confiance en lui-même (voir la doc + SumUp sur les webhooks et `app/api/forms.py:sumup_webhook`).""" + data = _call("GET", f"/v0.1/checkouts/{checkout_id}", api_key) + return _to_checkout(data) if data is not None else None diff --git a/backend/tests/test_forms_api.py b/backend/tests/test_forms_api.py index 48117f9..7d36699 100644 --- a/backend/tests/test_forms_api.py +++ b/backend/tests/test_forms_api.py @@ -7,8 +7,26 @@ import io from app.auth.rate_limit import form_submission_limiter from app.models import Role +from app.sumup import SumUpCheckout from tests.factories import make_artist, make_position +_MERCHANT = "MXXXXX" +_API_KEY = "sup_sk_secret" + + +def _configure_sumup(client, edition, amount=12.5, label="Cotisation classique"): + """Active le champ, renseigne les identifiants et crée un type de + paiement — renvoie son id, à passer en `payment_option_id` à `_submit`.""" + 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}, + ) + resp = client.post( + _form_url(edition, "/payment-options"), json={"label": label, "amount": amount} + ) + return resp.json()["id"] + # PNG 1×1 valide (transparent), le plus petit possible. _PNG = base64.b64decode( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY" @@ -74,26 +92,130 @@ def test_clearing_description_with_an_explicit_null(client, edition): assert resp.json()["description"] is None -def test_no_yapla_link_by_default(client, raw_client, edition): - assert client.get(_form_url(edition)).json()["yapla_url"] is None - assert raw_client.get(_form_url(edition, "/public")).json()["yapla_url"] is None +def test_sumup_not_configured_by_default(client, raw_client, edition): + body = client.get(_form_url(edition)).json() + assert body["sumup_merchant_code"] is None + assert body["sumup_api_key_set"] is False + public = raw_client.get(_form_url(edition, "/public")).json() + assert public["sumup_enabled"] is False + assert public["payment_options"] == [] -def test_setting_the_yapla_link(client, raw_client, edition): +def test_setting_sumup_credentials_never_echoes_the_api_key(client, raw_client, edition): resp = client.patch( - _form_url(edition), json={"yapla_url": "https://yapla.com/exemple/cotisation"} + _form_url(edition), + json={"sumup_merchant_code": "MXXXXX", "sumup_api_key": "sup_sk_secret"}, ) assert resp.status_code == 200 - assert resp.json()["yapla_url"] == "https://yapla.com/exemple/cotisation" + body = resp.json() + assert body["sumup_merchant_code"] == "MXXXXX" + assert body["sumup_api_key_set"] is True + assert "sumup_api_key" not in body - body = raw_client.get(_form_url(edition, "/public")).json() - assert body["yapla_url"] == "https://yapla.com/exemple/cotisation" + # Identifiants seuls, sans aucun type de paiement : pas encore activable. + client.put(_form_url(edition, "/fields"), json={"fields": ["WILLING_TO_PAY_FEE"]}) + assert raw_client.get(_form_url(edition, "/public")).json()["sumup_enabled"] is False + + client.post(_form_url(edition, "/payment-options"), json={"label": "Cotisation", "amount": 20}) + assert raw_client.get(_form_url(edition, "/public")).json()["sumup_enabled"] is True -def test_rejects_a_yapla_link_without_a_scheme(client, edition): - resp = client.patch(_form_url(edition), json={"yapla_url": "yapla.com/exemple"}) +def test_payment_options_start_empty(client, edition): + assert client.get(_form_url(edition, "/payment-options")).json() == [] + + +def test_creating_and_listing_payment_options(client, raw_client, edition): + client.post( + _form_url(edition, "/payment-options"), + json={"label": "Mécénat", "amount": 50, "order": 1}, + ) + resp = client.post( + _form_url(edition, "/payment-options"), + json={"label": "Cotisation classique", "amount": 20, "order": 0}, + ) + assert resp.status_code == 201 + body = resp.json() + assert body["label"] == "Cotisation classique" + assert body["amount"] == 20 + + listed = client.get(_form_url(edition, "/payment-options")).json() + assert [o["label"] for o in listed] == ["Cotisation classique", "Mécénat"] + + client.put(_form_url(edition, "/fields"), json={"fields": ["WILLING_TO_PAY_FEE"]}) + public_options = raw_client.get(_form_url(edition, "/public")).json()["payment_options"] + assert [o["label"] for o in public_options] == ["Cotisation classique", "Mécénat"] + assert [o["amount"] for o in public_options] == [20, 50] + + +def test_updating_and_deleting_a_payment_option(client, edition): + option_id = client.post( + _form_url(edition, "/payment-options"), json={"label": "Cotisation", "amount": 20} + ).json()["id"] + + resp = client.patch( + _form_url(edition, f"/payment-options/{option_id}"), json={"amount": 25} + ) + assert resp.status_code == 200 + assert resp.json()["amount"] == 25 + + resp = client.delete(_form_url(edition, f"/payment-options/{option_id}")) + assert resp.status_code == 204 + assert client.get(_form_url(edition, "/payment-options")).json() == [] + + +def test_payment_options_are_coordinator_only(edition, make_account, as_account): + lead = make_account(Role.LEAD, email="lead@e.fr") + lead_client = as_account(lead) + resp = lead_client.post( + _form_url(edition, "/payment-options"), json={"label": "Cotisation", "amount": 20} + ) + assert resp.status_code == 403 + + +def test_rejects_a_non_positive_payment_option_amount(client, edition): + resp = client.post( + _form_url(edition, "/payment-options"), json={"label": "Cotisation", "amount": 0} + ) assert resp.status_code == 422 - assert resp.json()["detail"]["code"] == "form.invalid_yapla_url" + assert resp.json()["detail"]["code"] == "form.invalid_sumup_amount" + + +def test_rejects_a_duplicate_payment_option_label(client, edition): + client.post(_form_url(edition, "/payment-options"), json={"label": "Cotisation", "amount": 20}) + resp = client.post( + _form_url(edition, "/payment-options"), json={"label": "Cotisation", "amount": 30} + ) + assert resp.status_code == 409 + assert resp.json()["detail"]["code"] == "form.duplicate_payment_option_label" + + +def test_deleting_a_payment_option_keeps_the_volunteers_snapshot( + 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, + ) + volunteer_id = resp.json()["volunteer_id"] + + client.delete(_form_url(edition, f"/payment-options/{option_id}")) + + fiche = client.get(f"/editions/{edition.id}/volunteers/{volunteer_id}").json() + assert fiche["fee_payment_label"] == "Cotisation classique" + assert fiche["fee_amount"] == 12.5 + + +def test_clearing_the_sumup_api_key(client, edition): + client.patch(_form_url(edition), json={"sumup_api_key": "sup_sk_secret"}) + assert client.get(_form_url(edition)).json()["sumup_api_key_set"] is True + resp = client.patch(_form_url(edition), json={"sumup_api_key": ""}) + assert resp.json()["sumup_api_key_set"] is False def test_logo_is_coordinator_only(edition, make_account, as_account): @@ -360,6 +482,115 @@ 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( + 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://checkout.sumup.com/pay/co_123" + ), + ) + 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["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" + assert fiche["fee_payment_label"] == "Cotisation classique" + assert fiche["fee_amount"] == 12.5 + assert fiche["willing_to_pay_fee"] is True + + +def test_submitting_without_choosing_an_option_does_not_create_a_checkout( + raw_client, client, edition, monkeypatch +): + _configure_sumup(client, edition) + 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" + ) + assert resp.status_code == 201 + assert resp.json()["sumup_checkout_url"] is None + assert called == [] + + +def test_submitting_with_an_unknown_payment_option_is_rejected(raw_client, client, edition): + _configure_sumup(client, edition) + resp = _submit( + raw_client, edition, + first_name="Alex", last_name="Martin", hcaptcha_token="x", + payment_option_id="00000000-0000-0000-0000-000000000000", + ) + assert resp.status_code == 422 + assert resp.json()["detail"]["code"] == "form.unknown_payment_option" + + +def test_a_failed_checkout_call_does_not_block_registration( + raw_client, client, edition, 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, + ) + 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 + + +def test_sumup_webhook_updates_the_payment_status(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, + ) + volunteer_id = resp.json()["volunteer_id"] + + monkeypatch.setattr( + "app.api.forms.get_checkout", + lambda **kw: SumUpCheckout(id="co_123", status="PAID", hosted_checkout_url=None), + ) + 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 + + 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 + + +def test_sumup_webhook_ignores_an_unknown_checkout(raw_client, client, edition, monkeypatch): + _configure_sumup(client, edition) + monkeypatch.setattr( + "app.api.forms.get_checkout", + lambda **kw: SumUpCheckout(id="co_unknown", status="PAID", hosted_checkout_url=None), + ) + resp = raw_client.post( + _form_url(edition, "/public/sumup-webhook"), + json={"event_type": "CHECKOUT_STATUS_CHANGED", "id": "co_unknown"}, + ) + assert resp.status_code == 200 + + 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 60c71ea..19a8354 100644 --- a/backend/tests/test_models.py +++ b/backend/tests/test_models.py @@ -31,6 +31,7 @@ EXPECTED_TABLES = { "badge_field", "form_template", "form_field", + "form_payment_option", } diff --git a/frontend/scripts/smoke.tsx b/frontend/scripts/smoke.tsx index 8820790..061954c 100644 --- a/frontend/scripts/smoke.tsx +++ b/frontend/scripts/smoke.tsx @@ -92,19 +92,19 @@ async function checkPublicForm() { const beforeCount = (await api.volunteers.list(e)).length; await api.forms.setFields(e, { - fields: ['EMAIL', 'POSITION_WISHES', 'ARTIST_WISHES', 'COMMENT'], + fields: ['EMAIL', 'POSITION_WISHES', 'ARTIST_WISHES', 'COMMENT', 'WILLING_TO_PAY_FEE'], }); const config = await api.forms.getPublicConfig(e); if (!config.is_open) throw new Error('formulaire public : devrait être ouvert par défaut'); if (config.positions.length !== positions.length) { throw new Error('formulaire public : postes manquants dans la config publique'); } - if (config.has_logo !== false || config.description !== null || config.yapla_url !== null) { - throw new Error('formulaire public : logo/description/lien Yapla devraient être vides par défaut'); + if (config.has_logo !== false || config.description !== null || config.sumup_enabled !== false) { + throw new Error('formulaire public : logo/description/SumUp devraient être vides par défaut'); } // Le recadrage carré (canvas) et l'upload du logo (FileReader) sont des // API navigateur — non testables sous Node (voir scripts/smoke.tsx tel - // qu'exécuté ici) ; seul le texte d'introduction et le lien Yapla sont + // qu'exécuté ici) ; seul le texte d'introduction et la config SumUp sont // vérifiables ici. await api.forms.setOpen(e, { description: 'Bienvenue au festival de test !' }); const configWithDescription = await api.forms.getPublicConfig(e); @@ -112,16 +112,24 @@ async function checkPublicForm() { throw new Error("formulaire public : le texte d'introduction ne revient pas correctement"); } - let invalidYaplaUrlRejectedWith = 'non'; + let invalidSumupAmountRejectedWith = 'non'; try { - await api.forms.setOpen(e, { yapla_url: 'yapla.com/exemple' }); + await api.forms.paymentOptions.create(e, { label: 'Test invalide', amount: 0 }); } catch (err) { - invalidYaplaUrlRejectedWith = err instanceof ApiError ? err.code : String(err); + invalidSumupAmountRejectedWith = err instanceof ApiError ? err.code : String(err); } - await api.forms.setOpen(e, { yapla_url: 'https://www.yapla.com/fr/exemple' }); - const configWithYapla = await api.forms.getPublicConfig(e); - if (configWithYapla.yapla_url !== 'https://www.yapla.com/fr/exemple') { - throw new Error('formulaire public : le lien Yapla ne revient pas correctement'); + await api.forms.setOpen(e, { + sumup_merchant_code: 'MXXXXXXX', + sumup_api_key: 'sup_sk_test', + }); + const classique = await api.forms.paymentOptions.create(e, { + label: 'Cotisation classique', + amount: 15, + }); + await api.forms.paymentOptions.create(e, { label: 'Mécénat', amount: 50 }); + const configWithSumup = await api.forms.getPublicConfig(e); + if (!configWithSumup.sumup_enabled || configWithSumup.payment_options.length !== 2) { + throw new Error('formulaire public : la configuration SumUp ne revient pas correctement'); } const sameDay = artists.filter((a) => a.performance_date === artists[0]?.performance_date); @@ -161,6 +169,7 @@ async function checkPublicForm() { comment: 'Créé par le smoke test', position_wish_ids: positions.slice(0, 2).map((p) => p.id), artist_wish_ids: artists[0] ? [artists[0].id] : [], + payment_option_id: classique.id, }); const created = await api.volunteers.get(e, result.volunteer_id); const afterCount = (await api.volunteers.list(e)).length; @@ -170,12 +179,14 @@ async function checkPublicForm() { { formOpenByDefault: config.is_open, descriptionRoundTrips: configWithDescription.description, - invalidYaplaUrlRejectedWith, - yaplaUrlRoundTrips: configWithYapla.yapla_url, + invalidSumupAmountRejectedWith, + sumupEnabledRoundTrips: configWithSumup.sumup_enabled, tooManyPositionsRejectedWith, tooManyArtistsRejectedWith, createdVolunteerName: `${created.first_name} ${created.last_name}`, createdVolunteerAppearsInList: afterCount === beforeCount + 1, + sumupCheckoutUrlReturned: result.sumup_checkout_url !== null, + createdVolunteerFeeLabel: created.fee_payment_label, }, null, 2, diff --git a/frontend/src/api/hooks.ts b/frontend/src/api/hooks.ts index 9796a93..1e2bc91 100644 --- a/frontend/src/api/hooks.ts +++ b/frontend/src/api/hooks.ts @@ -24,6 +24,8 @@ import type { DayPartUpdate, EditionUpdate, FormFieldsSet, + FormPaymentOptionCreate, + FormPaymentOptionUpdate, FormTemplateUpdate, ImportMapping, PlanningNotificationRequest, @@ -633,6 +635,44 @@ export function useUploadFormLogo() { }); } +/** Types de paiement de la cotisation (COORDINATION) — voir `FormPaymentOptionRead`. */ +export function useFormPaymentOptions() { + return useQuery({ + queryKey: ['formPaymentOptions', edition], + queryFn: () => api.forms.paymentOptions.list(edition), + }); +} + +function useInvalidateFormPaymentOptions() { + const client = useQueryClient(); + return () => client.invalidateQueries({ queryKey: ['formPaymentOptions', edition] }); +} + +export function useCreateFormPaymentOption() { + const invalidate = useInvalidateFormPaymentOptions(); + return useMutation({ + mutationFn: (body: FormPaymentOptionCreate) => api.forms.paymentOptions.create(edition, body), + onSuccess: invalidate, + }); +} + +export function useUpdateFormPaymentOption() { + const invalidate = useInvalidateFormPaymentOptions(); + return useMutation({ + mutationFn: ({ id, body }: { id: string; body: FormPaymentOptionUpdate }) => + api.forms.paymentOptions.update(edition, id, body), + onSuccess: invalidate, + }); +} + +export function useDeleteFormPaymentOption() { + const invalidate = useInvalidateFormPaymentOptions(); + return useMutation({ + mutationFn: (id: string) => api.forms.paymentOptions.remove(edition, id), + onSuccess: invalidate, + }); +} + /** URL objet (aperçu) du logo du formulaire public, ou `null` si absent. * Utilisable sans compte connecté (`GET .../form/logo` n'est pas protégé — * affiché sur `/inscription`). `refreshKey` (ex. `dataUpdatedAt` de diff --git a/frontend/src/api/http-client.ts b/frontend/src/api/http-client.ts index 0b604c5..bdb4e12 100644 --- a/frontend/src/api/http-client.ts +++ b/frontend/src/api/http-client.ts @@ -14,6 +14,8 @@ import type { DayPartUpdate, EditionUpdate, FormFieldsSet, + FormPaymentOptionCreate, + FormPaymentOptionUpdate, FormTemplateUpdate, ImportMapping, LoginRequest, @@ -296,6 +298,7 @@ export function createHttpApi(baseUrl: string): PlanningApi { 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)); } @@ -305,6 +308,21 @@ export function createHttpApi(baseUrl: string): PlanningApi { if (body.photo) form.append('photo', body.photo); return request(`${base(editionId)}/form/public/submit`, { method: 'POST', body: form }); }, + paymentOptions: { + list: (editionId) => request(`${base(editionId)}/form/payment-options`), + create: (editionId, body: FormPaymentOptionCreate) => + request(`${base(editionId)}/form/payment-options`, { + method: 'POST', + body: JSON.stringify(body), + }), + update: (editionId, id, body: FormPaymentOptionUpdate) => + request(`${base(editionId)}/form/payment-options/${id}`, { + method: 'PATCH', + body: JSON.stringify(body), + }), + remove: (editionId, id) => + request(`${base(editionId)}/form/payment-options/${id}`, { method: 'DELETE' }), + }, }, positionSheets: { generate: async (editionId, body: PositionSheetsRequest) => { diff --git a/frontend/src/api/mock/dataset.ts b/frontend/src/api/mock/dataset.ts index 1dd4220..9f27f6b 100644 --- a/frontend/src/api/mock/dataset.ts +++ b/frontend/src/api/mock/dataset.ts @@ -9,6 +9,7 @@ import type { DayPartRead, EditionRead, FormFieldType, + FormPaymentOptionRead, PositionCategory, PositionRead, ShiftRead, @@ -56,8 +57,10 @@ export interface MockState { logo: string | null; // data URL logoType: string | null; description: string | null; - yaplaUrl: string | null; + sumupMerchantCode: string | null; + sumupApiKey: string | null; }; + formPaymentOptions: FormPaymentOptionRead[]; /** volunteerId -> photo (formulaire public), data URL. */ photos: Map; seq: number; @@ -273,6 +276,10 @@ export function buildMockState(): MockState { companion_wish_note: null, self_declared_available: false, willing_to_pay_fee: false, + fee_payment_label: null, + fee_amount: null, + fee_payment_status: null, + fee_paid_at: null, created_at: NOW, updated_at: NOW, }); @@ -447,8 +454,10 @@ export function buildMockState(): MockState { fields: [], }, formTemplate: { - isOpen: true, fields: [], logo: null, logoType: null, description: null, yaplaUrl: null, + isOpen: true, fields: [], logo: null, logoType: null, description: null, + sumupMerchantCode: null, sumupApiKey: null, }, + formPaymentOptions: [], photos: new Map(), seq, }; diff --git a/frontend/src/api/mock/mock-client.ts b/frontend/src/api/mock/mock-client.ts index 5e312e0..ce89451 100644 --- a/frontend/src/api/mock/mock-client.ts +++ b/frontend/src/api/mock/mock-client.ts @@ -20,6 +20,9 @@ import type { DayPartUpdate, EditionUpdate, FormFieldsSet, + FormPaymentOptionCreate, + FormPaymentOptionRead, + FormPaymentOptionUpdate, FormSubmissionResult, FormTemplateRead, FormTemplateUpdate, @@ -122,15 +125,26 @@ const fileToDataUrl = (file: File): Promise => reader.readAsDataURL(file); }); +const sumupEnabled = (): boolean => + Boolean( + state.formTemplate.sumupMerchantCode && + state.formTemplate.sumupApiKey && + state.formPaymentOptions.length > 0, + ); + const formTemplateRead = (): FormTemplateRead => ({ edition_id: state.edition.id, is_open: state.formTemplate.isOpen, fields: state.formTemplate.fields, has_logo: state.formTemplate.logo !== null, description: state.formTemplate.description, - yapla_url: state.formTemplate.yaplaUrl, + sumup_merchant_code: state.formTemplate.sumupMerchantCode, + sumup_api_key_set: state.formTemplate.sumupApiKey !== null, }); +const sortedPaymentOptions = (): FormPaymentOptionRead[] => + [...state.formPaymentOptions].sort((a, b) => a.order - b.order || a.label.localeCompare(b.label)); + // PDF valide minimal (une page blanche) — remplace la vraie génération // (réservée au back-end réel) pour que le téléchargement fonctionne en mode // démonstration. @@ -856,6 +870,10 @@ export function createMockApi(): PlanningApi { companion_wish_note: null, self_declared_available: false, willing_to_pay_fee: false, + fee_payment_label: null, + fee_amount: null, + fee_payment_status: null, + fee_paid_at: null, created_at: now, updated_at: now, }; @@ -1191,6 +1209,10 @@ export function createMockApi(): PlanningApi { companion_wish_note: null, self_declared_available: false, willing_to_pay_fee: false, + fee_payment_label: null, + fee_amount: null, + fee_payment_status: null, + fee_paid_at: null, created_at: now, updated_at: now, }; @@ -1484,11 +1506,11 @@ export function createMockApi(): PlanningApi { setOpen: (_edition, body: FormTemplateUpdate) => { if (body.is_open !== undefined) state.formTemplate.isOpen = body.is_open; if (body.description !== undefined) state.formTemplate.description = body.description; - if (body.yapla_url !== undefined) { - if (body.yapla_url && !/^https?:\/\//.test(body.yapla_url)) { - throw new ApiError(422, 'form.invalid_yapla_url'); - } - state.formTemplate.yaplaUrl = body.yapla_url; + if (body.sumup_merchant_code !== undefined) { + state.formTemplate.sumupMerchantCode = body.sumup_merchant_code; + } + if (body.sumup_api_key !== undefined) { + state.formTemplate.sumupApiKey = body.sumup_api_key || null; } return delay(formTemplateRead()); }, @@ -1518,9 +1540,62 @@ export function createMockApi(): PlanningApi { : [], has_logo: state.formTemplate.logo !== null, description: state.formTemplate.description, - yapla_url: state.formTemplate.yaplaUrl, + sumup_enabled: sumupEnabled(), + payment_options: sortedPaymentOptions().map((o) => ({ + id: o.id, + label: o.label, + amount: o.amount, + })), }); }, + paymentOptions: { + list: (): Promise => delay(sortedPaymentOptions()), + create: (_edition, body: FormPaymentOptionCreate) => { + if (body.amount <= 0) throw new ApiError(422, 'form.invalid_sumup_amount'); + const label = body.label.trim(); + if (state.formPaymentOptions.some((o) => o.label === label)) { + throw new ApiError(409, 'form.duplicate_payment_option_label'); + } + const now = touch(); + const option: FormPaymentOptionRead = { + id: `pay${state.seq++}`, + edition_id: state.edition.id, + label, + amount: body.amount, + order: body.order ?? 0, + created_at: now, + updated_at: now, + }; + state.formPaymentOptions.push(option); + return delay(option); + }, + update: (_edition, id, body: FormPaymentOptionUpdate) => { + const option = state.formPaymentOptions.find((o) => o.id === id) ?? notFound(); + if (body.amount !== undefined && body.amount <= 0) { + throw new ApiError(422, 'form.invalid_sumup_amount'); + } + const label = body.label !== undefined ? body.label.trim() : option.label; + if ( + label !== option.label && + state.formPaymentOptions.some((o) => o.id !== id && o.label === label) + ) { + throw new ApiError(409, 'form.duplicate_payment_option_label'); + } + Object.assign(option, { + label, + ...(body.amount !== undefined ? { amount: body.amount } : {}), + ...(body.order !== undefined ? { order: body.order } : {}), + updated_at: touch(), + }); + return delay(option); + }, + remove: (_edition, id) => { + const index = state.formPaymentOptions.findIndex((o) => o.id === id); + if (index === -1) notFound(); + state.formPaymentOptions.splice(index, 1); + return delay(undefined); + }, + }, submitPublic: async (_edition, body: PublicFormSubmission) => { if (!state.formTemplate.isOpen) throw new ApiError(422, 'form.closed'); if (!body.hcaptcha_token) throw new ApiError(422, 'form.captcha_failed'); @@ -1539,6 +1614,12 @@ export function createMockApi(): PlanningApi { throw new ApiError(422, 'form.too_many_artists_same_day'); } + let paymentOption: FormPaymentOptionRead | null = null; + if (fields.has('WILLING_TO_PAY_FEE') && body.payment_option_id) { + paymentOption = state.formPaymentOptions.find((o) => o.id === body.payment_option_id) ?? null; + if (!paymentOption) throw new ApiError(422, 'form.unknown_payment_option'); + } + const now = touch(); const volunteer: VolunteerRead = { id: `v${state.seq++}`, @@ -1557,8 +1638,12 @@ export function createMockApi(): PlanningApi { : 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) + ? 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, }; @@ -1568,9 +1653,22 @@ export function createMockApi(): PlanningApi { state.photos.set(volunteer.id, await fileToDataUrl(body.photo)); } + // 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, []); - return delay({ volunteer_id: volunteer.id }); + return delay({ + volunteer_id: volunteer.id, + sumup_checkout_url: checkoutUrl, + }); }, }, diff --git a/frontend/src/api/schema.ts b/frontend/src/api/schema.ts index 8dff443..751166d 100644 --- a/frontend/src/api/schema.ts +++ b/frontend/src/api/schema.ts @@ -113,10 +113,20 @@ export interface VolunteerRead { companion_wish_note: string | null; self_declared_available: boolean; willing_to_pay_fee: boolean; + /** Paiement de la cotisation via SumUp (voir schema forms) — + * `fee_payment_label`/`fee_amount` : copie figée du type de paiement + * choisi (pas la config actuelle). `fee_payment_status` reste `null` tant + * qu'aucun checkout n'a jamais été créé pour ce bénévole. */ + fee_payment_label: string | null; + fee_amount: number | null; + fee_payment_status: FeePaymentStatus | null; + fee_paid_at: string | null; created_at: string; updated_at: string; } +export type FeePaymentStatus = 'PENDING' | 'PAID' | 'FAILED' | 'EXPIRED'; + export interface VolunteerCreate { first_name: string; last_name: string; @@ -573,14 +583,43 @@ export interface FormTemplateRead { fields: FormFieldType[]; has_logo: boolean; description: string | null; - yapla_url: string | null; + sumup_merchant_code: string | null; + /** `sumup_api_key` (secret) n'est jamais renvoyé — seulement s'il est défini. */ + sumup_api_key_set: boolean; } -/** `undefined`/absent = inchangé pour chaque champ. */ +/** `undefined`/absent = inchangé pour chaque champ. Chaîne vide sur + * `sumup_api_key` efface le secret enregistré. */ export interface FormTemplateUpdate { is_open?: boolean; description?: string | null; - yapla_url?: string | null; + sumup_merchant_code?: string | null; + sumup_api_key?: string | null; +} + +/** Un type de paiement proposé sur le formulaire public (ex. « Cotisation + * classique », « Mécénat ») — libellé + montant fixe, configurés librement + * par la coordination (comme les créneaux types `DayPart`). */ +export interface FormPaymentOptionRead { + id: string; + edition_id: string; + label: string; + amount: number; + order: number; + created_at: string; + updated_at: string; +} + +export interface FormPaymentOptionCreate { + label: string; + amount: number; + order?: number; +} + +export interface FormPaymentOptionUpdate { + label?: string; + amount?: number; + order?: number; } export interface PublicPositionOption { @@ -594,6 +633,14 @@ export interface PublicArtistOption { performance_date: string; } +/** Type de paiement affiché sur le formulaire public — un montant n'est pas + * un secret, contrairement aux identifiants SumUp. */ +export interface PublicPaymentOption { + id: string; + label: string; + amount: number; +} + /** Tout ce qu'il faut pour afficher le formulaire public — jamais plus. */ export interface PublicFormConfig { is_open: boolean; @@ -602,11 +649,16 @@ export interface PublicFormConfig { artists: PublicArtistOption[]; has_logo: boolean; description: string | null; - yapla_url: string | null; + /** `true` si l'édition a configuré SumUp (identifiants + au moins un type + * de paiement). */ + sumup_enabled: boolean; + payment_options: PublicPaymentOption[]; } export interface FormSubmissionResult { volunteer_id: string; + /** Lien de paiement hébergé SumUp, si un checkout a pu être créé. */ + sumup_checkout_url: string | null; } /** Champs du formulaire public — envoyés en `multipart/form-data` (photo @@ -622,6 +674,9 @@ export interface PublicFormSubmission { 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). */ + payment_option_id?: string; position_wish_ids?: string[]; artist_wish_ids?: string[]; photo?: File | null; diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 34d9026..5682208 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -24,6 +24,9 @@ import type { EditionRead, EditionUpdate, FormFieldsSet, + FormPaymentOptionCreate, + FormPaymentOptionRead, + FormPaymentOptionUpdate, FormSubmissionResult, FormTemplateRead, FormTemplateUpdate, @@ -173,6 +176,17 @@ export interface PlanningApi { getPublicConfig(editionId: string): Promise; submitPublic(editionId: string, body: PublicFormSubmission): Promise; logoBlob(editionId: string): Promise; + /** Types de paiement de la cotisation (COORDINATION uniquement). */ + paymentOptions: { + list(editionId: string): Promise; + create(editionId: string, body: FormPaymentOptionCreate): Promise; + update( + editionId: string, + id: string, + body: FormPaymentOptionUpdate, + ): Promise; + remove(editionId: string, id: 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 061333f..27140e8 100644 --- a/frontend/src/i18n/locales/fr.json +++ b/frontend/src/i18n/locales/fr.json @@ -381,6 +381,10 @@ "form.field.available": "Case « je suis disponible »", "form.field.available.help": "Purement informatif — n'a aucun lien avec les disponibilités détaillées de la fiche bénévole.", "form.field.willingToPayFee": "Case « je veux bien payer une cotisation »", + "config.vol.feeStatus.pending": "Cotisation : paiement en attente", + "config.vol.feeStatus.paid": "Cotisation payée ✓", + "config.vol.feeStatus.failed": "Cotisation : paiement échoué", + "config.vol.feeStatus.expired": "Cotisation : lien de paiement expiré", "form.field.photo": "Une magnifique photo de toi :", "form.field.comment": "Commentaire", "form.link.title": "Lien du formulaire", @@ -401,18 +405,28 @@ "form.customize.logo.uploading": "Envoi…", "form.customize.description": "Texte d'introduction", "form.customize.description.placeholder": "Un mot de bienvenue affiché en haut du formulaire (facultatif)…", - "form.customize.yaplaUrl": "Lien de cotisation Yapla", - "form.customize.yaplaUrl.help": "Facultatif : lien vers votre page de cotisation sur Yapla. Un encart « Payer ma cotisation » apparaît alors sur le formulaire public, près de la case correspondante.", - "form.customize.yaplaUrl.placeholder": "https://www.yapla.com/fr/...", + "form.customize.sumup.title": "Paiement de la cotisation (SumUp)", + "form.customize.sumup.help": "Facultatif : identifiants de votre compte marchand SumUp, et au moins un type de paiement ci-dessous (libellé + montant, ex. « Cotisation classique », « Mécénat »). Une fois les deux renseignés, le bénévole choisit un type à l'inscription et se voit proposer un lien de paiement juste après.", + "form.customize.sumup.merchantCode": "Code marchand", + "form.customize.sumup.merchantCode.placeholder": "MXXXXXXX", + "form.customize.sumup.apiKey": "Clé API", + "form.customize.sumup.apiKey.set": "(déjà enregistrée)", + "form.customize.sumup.apiKey.placeholder": "sup_sk_...", + "form.customize.paymentOptions.title": "Types de paiement", + "form.customize.paymentOptions.label": "Libellé (ex. Cotisation classique, Mécénat…)", + "form.customize.paymentOptions.add": "Ajouter", "publicForm.title": "Inscription bénévole", "publicForm.positionWishes.count": "{count} / {max}", "publicForm.artistWishes.none": "— Aucun —", "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 de 20€", + "publicForm.field.willingToPayFee": "Je participe à la cotisation bénévole", + "publicForm.field.paymentOption": "Cotisation", + "publicForm.paymentOption.none": "Je ne participe pas", "publicForm.photo.squareHint": "Recadrée automatiquement en carré.", - "publicForm.yapla.hint": "Le paiement se fait en ligne, via notre page Yapla.", - "publicForm.yapla.cta": "Payer ma cotisation ↗", + "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", @@ -428,7 +442,9 @@ "form.logo_type_not_supported": "Le logo doit être au format PNG ou JPEG.", "form.logo_empty": "Le logo est vide.", "form.logo_too_large": "Le logo est trop volumineux.", - "form.invalid_yapla_url": "Le lien Yapla doit commencer par http:// ou https://.", + "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.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 876e0c6..2340c12 100644 --- a/frontend/src/pages/FormBuilderPage.tsx +++ b/frontend/src/pages/FormBuilderPage.tsx @@ -2,9 +2,13 @@ import { useEffect, useRef, useState } from 'react'; import { ApiError } from '@/api'; import { + useCreateFormPaymentOption, + useDeleteFormPaymentOption, useFormLogoUrl, + useFormPaymentOptions, useFormTemplate, useSetFormFields, + useUpdateFormPaymentOption, useUpdateFormTemplate, useUploadFormLogo, } from '@/api/hooks'; @@ -60,11 +64,11 @@ const FIELD_HELP_KEY: Record = { export function FormBuilderPage() { const template = useFormTemplate(); const setFields = useSetFormFields(); - // Trois instances indépendantes du même mutateur (même endpoint PATCH) : + // Quatre instances indépendantes du même mutateur (même endpoint PATCH) : // le statut (pending/success) de l'une ne doit pas déteindre sur l'autre. const toggleOpen = useUpdateFormTemplate(); const saveDescription = useUpdateFormTemplate(); - const saveYaplaUrl = useUpdateFormTemplate(); + const saveSumUp = useUpdateFormTemplate(); const uploadLogo = useUploadFormLogo(); const logoUrl = useFormLogoUrl(template.data?.has_logo ?? false, template.dataUpdatedAt); const logoInputRef = useRef(null); @@ -72,7 +76,8 @@ export function FormBuilderPage() { const [enabled, setEnabled] = useState | null>(null); const [copied, setCopied] = useState(false); const [description, setDescription] = useState(''); - const [yaplaUrl, setYaplaUrl] = useState(''); + const [sumupMerchantCode, setSumupMerchantCode] = useState(''); + const [sumupApiKey, setSumupApiKey] = useState(''); useEffect(() => { if (template.data) setEnabled(new Set(template.data.fields)); @@ -81,7 +86,7 @@ export function FormBuilderPage() { if (template.data) setDescription(template.data.description ?? ''); }, [template.data]); useEffect(() => { - if (template.data) setYaplaUrl(template.data.yapla_url ?? ''); + if (template.data) setSumupMerchantCode(template.data.sumup_merchant_code ?? ''); }, [template.data]); if (template.isLoading || !enabled) return ; @@ -241,37 +246,167 @@ export function FormBuilderPage() { -
+
- {t('form.customize.yaplaUrl')} - + {t('form.customize.sumup.title')} +
- setYaplaUrl(e.target.value)} - placeholder={t('form.customize.yaplaUrl.placeholder')} - className="w-full border-2 border-ink bg-cream px-[9px] py-[7px] font-mono text-[11px]" - /> + +
+ + +
+
saveYaplaUrl.mutate({ yapla_url: yaplaUrl.trim() || null })} - disabled={saveYaplaUrl.isPending} + onClick={() => + saveSumUp.mutate( + { + sumup_merchant_code: sumupMerchantCode.trim() || null, + // Laissé vide = inchangé (le secret déjà enregistré n'est + // jamais renvoyé par l'API, donc jamais pré-rempli ici) — + // voir `sumup_api_key_set` pour savoir s'il y en a un. + ...(sumupApiKey.trim() ? { sumup_api_key: sumupApiKey.trim() } : {}), + }, + { onSuccess: () => setSumupApiKey('') }, + ) + } + disabled={saveSumUp.isPending} > - {saveYaplaUrl.isPending ? t('form.saving') : t('form.save')} + {saveSumUp.isPending ? t('form.saving') : t('form.save')} - {saveYaplaUrl.isSuccess ? ( + {saveSumUp.isSuccess ? ( {t('form.saved')} ) : null} - {saveYaplaUrl.isError ? ( + {saveSumUp.isError ? ( - {errorMessage(saveYaplaUrl.error)} + {errorMessage(saveSumUp.error)} ) : null}
+ +
+ {t('form.customize.paymentOptions.title')} + +
); } + +/** Types de paiement (libellé + montant fixe) — liste libre, même principe + * que `DayPartsForm` (`ConfigRules.tsx`) : édition en ligne (au blur), + * ajout en bas, suppression immédiate. */ +function PaymentOptionsForm() { + const options = useFormPaymentOptions(); + const create = useCreateFormPaymentOption(); + const update = useUpdateFormPaymentOption(); + const remove = useDeleteFormPaymentOption(); + const [label, setLabel] = useState(''); + const [amount, setAmount] = useState(''); + + if (options.isLoading) return
…
; + if (options.error) return ; + + const rows = [...(options.data ?? [])].sort( + (a, b) => a.order - b.order || a.label.localeCompare(b.label), + ); + + return ( +
+ {rows.map((o) => ( +
+ { + const v = e.target.value.trim(); + if (v && v !== o.label) update.mutate({ id: o.id, body: { label: v } }); + }} + className="min-w-0 flex-1 border-2 border-ink bg-paper px-2 py-1 font-display text-[12px] font-bold" + /> + { + const v = Number(e.target.value); + if (v > 0 && v !== o.amount) update.mutate({ id: o.id, body: { amount: v } }); + }} + className="w-[90px] border-2 border-ink bg-paper px-1 py-1 font-mono text-[11px]" + /> + € + +
+ ))} + +
+ setLabel(e.target.value)} + placeholder={t('form.customize.paymentOptions.label')} + className="min-w-0 flex-1 border-2 border-ink bg-yellow px-2 py-1 font-display text-[12px] font-bold" + /> + setAmount(e.target.value)} + placeholder="20" + className="w-[90px] border-2 border-ink bg-paper px-1 py-1 font-mono text-[11px]" + /> + € + +
+ {create.isError ? : null} + {update.isError ? : null} +
+ ); +} diff --git a/frontend/src/pages/PublicSignupPage.tsx b/frontend/src/pages/PublicSignupPage.tsx index 239d289..26997ec 100644 --- a/frontend/src/pages/PublicSignupPage.tsx +++ b/frontend/src/pages/PublicSignupPage.tsx @@ -138,6 +138,7 @@ export function PublicSignupPage() { const [companionNote, setCompanionNote] = useState(''); const [available, setAvailable] = useState(false); const [willingToPayFee, setWillingToPayFee] = useState(false); + const [paymentOptionId, setPaymentOptionId] = useState(''); const [positionIds, setPositionIds] = useState([]); const [artistByDay, setArtistByDay] = useState>({}); const [photo, setPhoto] = useState(null); @@ -145,6 +146,7 @@ export function PublicSignupPage() { const [captchaToken, setCaptchaToken] = useState(''); const [submitError, setSubmitError] = useState(null); const [volunteerId, setVolunteerId] = useState(null); + const [sumupCheckoutUrl, setSumupCheckoutUrl] = useState(null); const artistsByDay = useMemo( () => groupArtistsByDay(config.data?.artists ?? []), @@ -180,7 +182,24 @@ export function PublicSignupPage() { {t('publicForm.success.title')} -

{t('publicForm.success.body')}

+
+

{t('publicForm.success.body')}

+ {sumupCheckoutUrl ? ( +
+ + {t('publicForm.sumup.hint')} + + + {t('publicForm.sumup.cta')} + +
+ ) : null} +
); @@ -238,11 +257,16 @@ export function PublicSignupPage() { companion_wish_note: fields.has('COMPANION_WISH') ? companionNote : undefined, available: fields.has('AVAILABLE') ? available : undefined, willing_to_pay_fee: fields.has('WILLING_TO_PAY_FEE') ? willingToPayFee : undefined, + payment_option_id: + fields.has('WILLING_TO_PAY_FEE') && config.data.sumup_enabled + ? paymentOptionId || undefined + : undefined, position_wish_ids: fields.has('POSITION_WISHES') ? positionIds : undefined, artist_wish_ids: fields.has('ARTIST_WISHES') ? Object.values(artistByDay) : undefined, photo: fields.has('PHOTO') ? photo : undefined, }); setVolunteerId(result.volunteer_id); + setSumupCheckoutUrl(result.sumup_checkout_url); } catch (err) { setSubmitError(errorMessage(err)); } @@ -374,7 +398,43 @@ export function PublicSignupPage() { ) : null} - {fields.has('WILLING_TO_PAY_FEE') ? ( + {fields.has('WILLING_TO_PAY_FEE') && config.data.sumup_enabled ? ( +
+ {t('publicForm.field.paymentOption')} +
+ + {config.data.payment_options.map((o) => ( + + ))} +
+ {paymentOptionId ? ( +

+ {t('publicForm.sumup.afterSubmitHint')} +

+ ) : null} +
+ ) : fields.has('WILLING_TO_PAY_FEE') ? (