Formulaire public : paiement de la cotisation via l'API SumUp (remplace Yapla)
ci-cd / build-backend (push) Failing after 4m12s
ci-cd / build-frontend (push) Skipped
ci-cd / deploy (push) Skipped

Remplace le simple lien Yapla statique par une vraie intégration API SumUp :
checkout hébergé créé à la soumission du formulaire, statut de paiement
(PENDING/PAID/FAILED/EXPIRED) tenu à jour par un webhook SumUp et visible sur
la fiche du bénévole. La cotisation n'est plus un montant unique : la
coordination configure autant de types de paiement qu'elle veut (libellé +
montant, ex. « Cotisation classique », « Mécénat »), et le bénévole en
choisit un à l'inscription — le libellé et le montant choisis sont figés sur
le bénévole, indépendamment d'une modification ultérieure de la config.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
AdamThomas
2026-09-29 19:56:04 +02:00
co-authored by Claude Sonnet 5
parent 0be5daa89c
commit 0bd38a6465
25 changed files with 1457 additions and 125 deletions
+241 -15
View File
@@ -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)