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)
+68 -4
View File
@@ -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
+3 -1
View File
@@ -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),
+3 -1
View File
@@ -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.",
@@ -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))
@@ -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")
+4
View File
@@ -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",
+64 -10
View File
@@ -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):
+12
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
from app.sumup.client import SumUpCheckout, create_checkout, get_checkout
__all__ = ["SumUpCheckout", "create_checkout", "get_checkout"]
+92
View File
@@ -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
+242 -11
View File
@@ -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))
+1
View File
@@ -31,6 +31,7 @@ EXPECTED_TABLES = {
"badge_field",
"form_template",
"form_field",
"form_payment_option",
}