Compare commits

..
2 Commits
Author SHA1 Message Date
AdamThomasandClaude Sonnet 5 0bd38a6465 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>
2026-09-29 19:56:04 +02:00
AdamThomas 0be5daa89c fix: update ci 2026-09-29 18:31:08 +02:00
26 changed files with 1466 additions and 195 deletions
+9 -70
View File
@@ -1,6 +1,5 @@
# CI/CD Gitea Actions : tests à chaque push, puis sur `main` seulement, # CI/CD Gitea Actions : à chaque push sur `main`, build des images
# build des images (registre de conteneurs intégré à Gitea) et déploiement # (registre de conteneurs intégré à Gitea) puis déploiement SSH sur le VPS.
# SSH sur le VPS.
# #
# Nécessite un runner Gitea Actions (act_runner, exécuteur "docker") attaché # Nécessite un runner Gitea Actions (act_runner, exécuteur "docker") attaché
# au dépôt ou à l'organisation. # au dépôt ou à l'organisation.
@@ -27,72 +26,14 @@ name: ci-cd
on: on:
push: push:
branches: ['**'] branches: [main]
concurrency:
group: ci-cd-${{ gitea.ref }}
cancel-in-progress: false
jobs: jobs:
backend-test:
runs-on: ubuntu-latest
container: python:3.12-slim
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: benevoles
POSTGRES_PASSWORD: benevoles
POSTGRES_DB: benevoles_test
ports:
- 5432:5432
env:
TEST_DATABASE_URL: postgresql+psycopg://benevoles:benevoles@postgres:5432/benevoles_test
DATABASE_URL: postgresql+psycopg://benevoles:benevoles@postgres:5432/benevoles_test
steps:
- name: Installer git et node
run: apt-get update && apt-get install -y --no-install-recommends git nodejs
- uses: actions/checkout@v4
- name: Installer uv
run: pip install --no-cache-dir uv
- name: Installer les dépendances
working-directory: backend
run: uv sync
- name: Lint
working-directory: backend
run: uv run ruff check .
- name: Migrer la base de test
working-directory: backend
run: uv run alembic upgrade head
- name: Vérifier migration ↔ modèle
working-directory: backend
run: uv run alembic check
- name: Tests
working-directory: backend
run: uv run pytest -q
frontend-test:
runs-on: ubuntu-latest
container: node:20-alpine
steps:
- name: Installer git et node
run: apk add --no-cache git nodejs
- uses: actions/checkout@v4
- name: Installer les dépendances
working-directory: frontend
run: npm ci
- name: Typecheck
working-directory: frontend
run: npm run typecheck
- name: Lint
working-directory: frontend
run: npm run lint
- name: Build
working-directory: frontend
run: npm run build
- name: Smoke test
working-directory: frontend
run: npm run smoke
build-backend: build-backend:
needs: backend-test
if: gitea.ref == 'refs/heads/main'
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: docker:27 container: docker:27
steps: steps:
@@ -109,8 +50,7 @@ jobs:
docker push "$IMAGE:latest" docker push "$IMAGE:latest"
build-frontend: build-frontend:
needs: frontend-test needs: build-backend # séquentiel pour ne pas saturer le CPU
if: gitea.ref == 'refs/heads/main'
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: docker:27 container: docker:27
steps: steps:
@@ -133,7 +73,6 @@ jobs:
deploy: deploy:
needs: [build-backend, build-frontend] needs: [build-backend, build-frontend]
if: gitea.ref == 'refs/heads/main'
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: alpine:3.20 container: alpine:3.20
steps: steps:
@@ -157,4 +96,4 @@ jobs:
docker compose -f docker-compose.prod.yml run --rm backend uv run alembic upgrade head docker compose -f docker-compose.prod.yml run --rm backend uv run alembic upgrade head
docker compose -f docker-compose.prod.yml up -d docker compose -f docker-compose.prod.yml up -d
docker image prune -f docker image prune -f
EOF EOF
+60 -10
View File
@@ -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 forcément encore en base au moment de l'inscription, donc pas de lien
direct vers un `VolunteerCompanionWish` ; la coordination la résout à la direct vers un `VolunteerCompanionWish` ; la coordination la résout à la
main depuis la fiche une fois les deux bénévoles inscrits), main depuis la fiche une fois les deux bénévoles inscrits),
`self_declared_available`/`willing_to_pay_fee` (cases à cocher **purement `self_declared_available` (case à cocher **purement informative**, sans
informatives**, sans lien avec `Availability` ni aucun workflow de lien avec `Availability`) et `willing_to_pay_fee` (« je participe à la
paiement). 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`, - **`Artist`** : programmation de l'édition (`name`, `performance_date`,
`start_time`, `end_time` — `end_time <= start_time` ⇒ le passage finit le `start_time`, `end_time` — `end_time <= start_time` ⇒ le passage finit le
lendemain). Unique (`edition_id`, `name`, `performance_date`). La 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). affectation n'apparaît pas (rien à imprimer).
- **`FormTemplate`** : configuration du formulaire public d'inscription - **`FormTemplate`** : configuration du formulaire public d'inscription
d'une édition (une ligne, créée à la demande). `is_open` coupe les 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` ∈ - **`FormField`** : un champ activé sur le formulaire (`field_type` ∈
`EMAIL` / `PHONE` / `POSITION_WISHES` / `ARTIST_WISHES` / `COMPANION_WISH` `EMAIL` / `PHONE` / `POSITION_WISHES` / `ARTIST_WISHES` / `COMPANION_WISH`
/ `AVAILABLE` / `WILLING_TO_PAY_FEE` / `PHOTO` / `COMMENT`, au plus un par / `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 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`). souhaité par jour de festival (`app/api/forms.py:_check_one_artist_wish_per_day`).
`GET/PUT/PATCH /editions/{id}/form[...]` (COORDINATION) configurent ; `GET/PUT/PATCH /editions/{id}/form[...]` (COORDINATION) configurent ;
`GET /editions/{id}/form/public` et `POST .../form/public/submit` sont les `GET /editions/{id}/form/public`, `POST .../form/public/submit` et
**deux seuls endpoints de toute l'API sans authentification** — protégés `POST .../form/public/sumup-webhook` sont les **trois seuls endpoints de
par hCaptcha (`app/forms/captcha.py`, `HCAPTCHA_SECRET_KEY` ; vérification 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 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`, 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 hCaptcha reste la défense principale) ; le webhook est appelé par SumUp
apparaît immédiatement dans la liste de la coordination, comme n'importe lui-même, donc sans hCaptcha possible — voir plus bas. Le bénévole créé
quel autre bénévole. 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 : Conventions techniques :
@@ -537,6 +585,7 @@ l'étape 21.)*
│ │ ├── badges/ # génération PDF des planches (pdf.py, ReportLab) │ │ ├── badges/ # génération PDF des planches (pdf.py, ReportLab)
│ │ ├── sheets/ # génération PDF des feuilles de poste (pdf.py) │ │ ├── sheets/ # génération PDF des feuilles de poste (pdf.py)
│ │ ├── forms/ # hCaptcha (captcha.py) — voir api/forms.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 │ │ ├── bootstrap.py # instance vierge : édition + 1er compte
│ │ ├── seed.py # jeu de démonstration (python -m app.seed) │ │ ├── seed.py # jeu de démonstration (python -m app.seed)
│ │ ├── i18n/ │ │ ├── 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 | | 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 | | 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 | | 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 **Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur
de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans + de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans +
+241 -15
View File
@@ -2,11 +2,12 @@
Deux familles d'endpoints sous le même routeur : Deux familles d'endpoints sous le même routeur :
- configuration (COORDINATION) : quels champs sont activés, formulaire - configuration (COORDINATION) : quels champs sont activés, formulaire
ouvert ou fermé ; ouvert ou fermé, identifiants SumUp de paiement de la cotisation ;
- publics (**aucune authentification** — les seuls endpoints de toute - publics (**aucune authentification**) : consultation de la configuration
l'API dans ce cas) : consultation de la configuration et soumission, et soumission (protégées par hCaptcha, `app/forms/captcha.py`, et un filet
protégés par hCaptcha (`app/forms/captcha.py`) et un filet de sécurité de sécurité global, `app/auth/rate_limit.py:form_submission_limiter`), et
global (`app/auth/rate_limit.py:form_submission_limiter`). 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 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 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`).""" CLAUDE.md § 2, `Volunteer`)."""
import json import json
import logging
import uuid import uuid
from fastapi import ( from fastapi import (
@@ -33,33 +35,46 @@ from fastapi import (
status, status,
) )
from sqlalchemy import delete from sqlalchemy import delete
from sqlalchemy.exc import IntegrityError
from sqlmodel import Session, select 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.deps import get_edition
from app.api.schemas import ( from app.api.schemas import (
FormFieldsSet, FormFieldsSet,
FormPaymentOptionCreate,
FormPaymentOptionRead,
FormPaymentOptionUpdate,
FormSubmissionResult, FormSubmissionResult,
FormTemplateRead, FormTemplateRead,
FormTemplateUpdate, FormTemplateUpdate,
PublicArtistOption, PublicArtistOption,
PublicFormConfig, PublicFormConfig,
PublicPaymentOption,
PublicPositionOption, PublicPositionOption,
SumUpWebhookPayload,
) )
from app.auth.deps import require_coordinator from app.auth.deps import require_coordinator
from app.auth.rate_limit import form_submission_limiter from app.auth.rate_limit import form_submission_limiter
from app.config import get_settings
from app.db import get_session from app.db import get_session
from app.forms import verify_hcaptcha from app.forms import verify_hcaptcha
from app.models import ( from app.models import (
Account, Account,
Artist, Artist,
Edition, Edition,
FeePaymentStatus,
FormField, FormField,
FormFieldType, FormFieldType,
FormPaymentOption,
FormTemplate, FormTemplate,
Position, Position,
Volunteer, 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"]) 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: def _template_read(template: FormTemplate, fields: list[FormFieldType]) -> FormTemplateRead:
return FormTemplateRead( return FormTemplateRead(
edition_id=template.edition_id, edition_id=template.edition_id,
@@ -102,7 +131,8 @@ def _template_read(template: FormTemplate, fields: list[FormFieldType]) -> FormT
fields=fields, fields=fields,
has_logo=template.logo is not None, has_logo=template.logo is not None,
description=template.description, 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: ) -> FormTemplateRead:
template = _get_or_create_template(session, edition.id) template = _get_or_create_template(session, edition.id)
changes = payload.model_dump(exclude_unset=True) changes = payload.model_dump(exclude_unset=True)
if changes.get("yapla_url"): if changes.get("sumup_api_key") == "":
if not changes["yapla_url"].startswith(("http://", "https://")): changes["sumup_api_key"] = None
raise HTTPException(
status_code=422, detail={"code": "form.invalid_yapla_url"}
)
for field, value in changes.items(): for field, value in changes.items():
setattr(template, field, value) setattr(template, field, value)
session.add(template) session.add(template)
@@ -152,6 +179,96 @@ def update_form(
return _template_read(template, _enabled_fields(session, edition.id)) 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]: async def _read_logo(file: UploadFile) -> tuple[bytes, str]:
content_type = file.content_type or "" content_type = file.content_type or ""
if content_type not in _ALLOWED_PHOTO_TYPES: if content_type not in _ALLOWED_PHOTO_TYPES:
@@ -233,6 +350,7 @@ def get_public_form(
) )
] ]
options = _payment_options(session, edition.id)
return PublicFormConfig( return PublicFormConfig(
is_open=template.is_open, is_open=template.is_open,
fields=fields, fields=fields,
@@ -240,7 +358,10 @@ def get_public_form(
artists=artists, artists=artists,
has_logo=template.logo is not None, has_logo=template.logo is not None,
description=template.description, 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), companion_wish_note: str | None = Form(None),
available: bool = Form(False), available: bool = Form(False),
willing_to_pay_fee: bool = Form(False), willing_to_pay_fee: bool = Form(False),
payment_option_id: str | None = Form(None),
position_wish_ids: str | None = Form(None), position_wish_ids: str | None = Form(None),
artist_wish_ids: str | None = Form(None), artist_wish_ids: str | None = Form(None),
photo: UploadFile | None = File(None), photo: UploadFile | None = File(None),
@@ -335,6 +457,20 @@ async def submit_public_form(
if artist_ids: if artist_ids:
_check_one_artist_wish_per_day(session, edition.id, 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( volunteer = Volunteer(
edition_id=edition.id, edition_id=edition.id,
first_name=first_name.strip().title(), first_name=first_name.strip().title(),
@@ -352,8 +488,13 @@ async def submit_public_form(
self_declared_available=( self_declared_available=(
available if FormFieldType.AVAILABLE in fields else False 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=(
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.commit()
session.refresh(volunteer) 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, AssignmentSource,
BadgeAlign, BadgeAlign,
BadgeFieldType, BadgeFieldType,
FeePaymentStatus,
FormFieldType, FormFieldType,
PositionCategory, PositionCategory,
Role, Role,
@@ -92,6 +93,15 @@ class VolunteerRead(ApiModel):
companion_wish_note: str | None companion_wish_note: str | None
self_declared_available: bool self_declared_available: bool
willing_to_pay_fee: 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 created_at: dt.datetime
updated_at: dt.datetime updated_at: dt.datetime
@@ -493,16 +503,43 @@ class FormTemplateRead(ApiModel):
fields: list[FormFieldType] fields: list[FormFieldType]
has_logo: bool has_logo: bool
description: str | None 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): class FormTemplateUpdate(ApiModel):
"""`None`/absent = inchangé pour chaque champ (`exclude_unset`) — permet """`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 is_open: bool | None = None
description: str | 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): class PublicPositionOption(ApiModel):
@@ -516,6 +553,15 @@ class PublicArtistOption(ApiModel):
performance_date: dt.date 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): class PublicFormConfig(ApiModel):
"""Tout ce qu'il faut pour afficher le formulaire public — jamais plus : """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 ni les bénévoles déjà inscrits, ni les créneaux/affectations de
@@ -527,8 +573,26 @@ class PublicFormConfig(ApiModel):
artists: list[PublicArtistOption] artists: list[PublicArtistOption]
has_logo: bool has_logo: bool
description: str | None 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): class FormSubmissionResult(ApiModel):
volunteer_id: uuid.UUID 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: def _read(session: Session, volunteer: Volunteer) -> VolunteerRead:
return 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), artist_ids=_artist_ids(session, volunteer.id),
wished_position_ids=_wished_position_ids(session, volunteer.id), wished_position_ids=_wished_position_ids(session, volunteer.id),
wished_companion_ids=_companion_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_type_not_supported": "Le logo doit être au format PNG ou JPEG.",
"form.logo_empty": "Le logo est vide.", "form.logo_empty": "Le logo est vide.",
"form.logo_too_large": "Le logo est trop volumineux.", "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_position_wishes": "Deux vœux de poste au maximum.",
"form.too_many_artists_same_day": "Un seul artiste souhaité par soir de festival.", "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.", "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, DayPart,
Edition, Edition,
FormField, FormField,
FormPaymentOption,
FormTemplate, FormTemplate,
MagicLinkToken, MagicLinkToken,
Position, Position,
@@ -28,6 +29,7 @@ from app.models.enums import (
AssignmentSource, AssignmentSource,
BadgeAlign, BadgeAlign,
BadgeFieldType, BadgeFieldType,
FeePaymentStatus,
FormFieldType, FormFieldType,
PositionCategory, PositionCategory,
Role, Role,
@@ -45,8 +47,10 @@ __all__ = [
"BadgeTemplate", "BadgeTemplate",
"DayPart", "DayPart",
"Edition", "Edition",
"FeePaymentStatus",
"FormField", "FormField",
"FormFieldType", "FormFieldType",
"FormPaymentOption",
"FormTemplate", "FormTemplate",
"MagicLinkToken", "MagicLinkToken",
"Position", "Position",
+64 -10
View File
@@ -18,6 +18,7 @@ from app.models.enums import (
AssignmentSource, AssignmentSource,
BadgeAlign, BadgeAlign,
BadgeFieldType, BadgeFieldType,
FeePaymentStatus,
FormFieldType, FormFieldType,
PositionCategory, PositionCategory,
Role, Role,
@@ -192,6 +193,24 @@ class Volunteer(TimestampMixin, table=True):
default=False, default=False,
sa_column=Column(Boolean, nullable=False, server_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( wished_artists: list[Artist] = Relationship(
back_populates="fans", back_populates="fans",
@@ -467,10 +486,12 @@ class FormTemplate(TimestampMixin, table=True):
"""Configuration du formulaire public d'une édition. Une ligne par """Configuration du formulaire public d'une édition. Une ligne par
édition (créée à la demande). `is_open` coupe les nouvelles soumissions édition (créée à la demande). `is_open` coupe les nouvelles soumissions
sans effacer la configuration (les champs activés restent enregistrés). sans effacer la configuration (les champs activés restent enregistrés).
`logo`/`logo_content_type`, `description` et `yapla_url` habillent la `logo`/`logo_content_type` et `description` habillent la page publique
page publique (`/inscription`) — aucun rapport avec les champs collectés (`/inscription`) — aucun rapport avec les champs collectés par
par `FormField` ; stockage en base comme les autres images de l'appli `FormField` ; stockage en base comme les autres images de l'appli
(badges, photo de bénévole), pas de volume de fichiers à gérer.""" (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" __tablename__ = "form_template"
@@ -485,12 +506,45 @@ class FormTemplate(TimestampMixin, table=True):
logo: bytes | None = Field(default=None, sa_column=Column(LargeBinary)) logo: bytes | None = Field(default=None, sa_column=Column(LargeBinary))
logo_content_type: str | None = None logo_content_type: str | None = None
description: str | None = None description: str | None = None
# Lien vers la page de cotisation Yapla (yapla.com, plateforme externe de # Paiement de la cotisation via l'API SumUp (`app/sumup/`) : un checkout
# paiement pour associations) — encart affiché sur `/inscription` près de # est créé à la soumission si le bénévole choisit un des types de
# la case « je participe à la cotisation » (`FormFieldType. # paiement configurés (`FormPaymentOption`, voir plus bas) et que ces
# WILLING_TO_PAY_FEE`). Rien n'est vérifié côté appli : la case reste # deux identifiants sont renseignés (voir `app/api/forms.py`,
# une déclaration, le paiement se passe entièrement chez Yapla. # `sumup_enabled` sur `PublicFormConfig`). `sumup_api_key` n'est jamais
yapla_url: str | None = None # 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): class FormField(SQLModel, table=True):
+12
View File
@@ -45,6 +45,18 @@ class BadgeAlign(enum.StrEnum):
RIGHT = "RIGHT" 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): class FormFieldType(enum.StrEnum):
"""Champ activable sur le formulaire public d'inscription (voir """Champ activable sur le formulaire public d'inscription (voir
`app/api/forms.py`). Nom/prénom sont toujours collectés, pas au choix de `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.auth.rate_limit import form_submission_limiter
from app.models import Role from app.models import Role
from app.sumup import SumUpCheckout
from tests.factories import make_artist, make_position 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 1×1 valide (transparent), le plus petit possible.
_PNG = base64.b64decode( _PNG = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY" "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY"
@@ -74,26 +92,130 @@ def test_clearing_description_with_an_explicit_null(client, edition):
assert resp.json()["description"] is None assert resp.json()["description"] is None
def test_no_yapla_link_by_default(client, raw_client, edition): def test_sumup_not_configured_by_default(client, raw_client, edition):
assert client.get(_form_url(edition)).json()["yapla_url"] is None body = client.get(_form_url(edition)).json()
assert raw_client.get(_form_url(edition, "/public")).json()["yapla_url"] is None 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( 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.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() # Identifiants seuls, sans aucun type de paiement : pas encore activable.
assert body["yapla_url"] == "https://yapla.com/exemple/cotisation" 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): def test_payment_options_start_empty(client, edition):
resp = client.patch(_form_url(edition), json={"yapla_url": "yapla.com/exemple"}) 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.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): 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" 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): def test_submitting_is_rate_limited_per_edition(raw_client, edition):
for _ in range(form_submission_limiter.max_attempts): for _ in range(form_submission_limiter.max_attempts):
form_submission_limiter.hit(str(edition.id)) form_submission_limiter.hit(str(edition.id))
+1
View File
@@ -31,6 +31,7 @@ EXPECTED_TABLES = {
"badge_field", "badge_field",
"form_template", "form_template",
"form_field", "form_field",
"form_payment_option",
} }
+24 -13
View File
@@ -92,19 +92,19 @@ async function checkPublicForm() {
const beforeCount = (await api.volunteers.list(e)).length; const beforeCount = (await api.volunteers.list(e)).length;
await api.forms.setFields(e, { 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); const config = await api.forms.getPublicConfig(e);
if (!config.is_open) throw new Error('formulaire public : devrait être ouvert par défaut'); if (!config.is_open) throw new Error('formulaire public : devrait être ouvert par défaut');
if (config.positions.length !== positions.length) { if (config.positions.length !== positions.length) {
throw new Error('formulaire public : postes manquants dans la config publique'); throw new Error('formulaire public : postes manquants dans la config publique');
} }
if (config.has_logo !== false || config.description !== null || config.yapla_url !== null) { if (config.has_logo !== false || config.description !== null || config.sumup_enabled !== false) {
throw new Error('formulaire public : logo/description/lien Yapla devraient être vides par défaut'); 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 // Le recadrage carré (canvas) et l'upload du logo (FileReader) sont des
// API navigateur — non testables sous Node (voir scripts/smoke.tsx tel // 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. // vérifiables ici.
await api.forms.setOpen(e, { description: 'Bienvenue au festival de test !' }); await api.forms.setOpen(e, { description: 'Bienvenue au festival de test !' });
const configWithDescription = await api.forms.getPublicConfig(e); 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"); throw new Error("formulaire public : le texte d'introduction ne revient pas correctement");
} }
let invalidYaplaUrlRejectedWith = 'non'; let invalidSumupAmountRejectedWith = 'non';
try { try {
await api.forms.setOpen(e, { yapla_url: 'yapla.com/exemple' }); await api.forms.paymentOptions.create(e, { label: 'Test invalide', amount: 0 });
} catch (err) { } 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' }); await api.forms.setOpen(e, {
const configWithYapla = await api.forms.getPublicConfig(e); sumup_merchant_code: 'MXXXXXXX',
if (configWithYapla.yapla_url !== 'https://www.yapla.com/fr/exemple') { sumup_api_key: 'sup_sk_test',
throw new Error('formulaire public : le lien Yapla ne revient pas correctement'); });
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); 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', comment: 'Créé par le smoke test',
position_wish_ids: positions.slice(0, 2).map((p) => p.id), position_wish_ids: positions.slice(0, 2).map((p) => p.id),
artist_wish_ids: artists[0] ? [artists[0].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 created = await api.volunteers.get(e, result.volunteer_id);
const afterCount = (await api.volunteers.list(e)).length; const afterCount = (await api.volunteers.list(e)).length;
@@ -170,12 +179,14 @@ async function checkPublicForm() {
{ {
formOpenByDefault: config.is_open, formOpenByDefault: config.is_open,
descriptionRoundTrips: configWithDescription.description, descriptionRoundTrips: configWithDescription.description,
invalidYaplaUrlRejectedWith, invalidSumupAmountRejectedWith,
yaplaUrlRoundTrips: configWithYapla.yapla_url, sumupEnabledRoundTrips: configWithSumup.sumup_enabled,
tooManyPositionsRejectedWith, tooManyPositionsRejectedWith,
tooManyArtistsRejectedWith, tooManyArtistsRejectedWith,
createdVolunteerName: `${created.first_name} ${created.last_name}`, createdVolunteerName: `${created.first_name} ${created.last_name}`,
createdVolunteerAppearsInList: afterCount === beforeCount + 1, createdVolunteerAppearsInList: afterCount === beforeCount + 1,
sumupCheckoutUrlReturned: result.sumup_checkout_url !== null,
createdVolunteerFeeLabel: created.fee_payment_label,
}, },
null, null,
2, 2,
+40
View File
@@ -24,6 +24,8 @@ import type {
DayPartUpdate, DayPartUpdate,
EditionUpdate, EditionUpdate,
FormFieldsSet, FormFieldsSet,
FormPaymentOptionCreate,
FormPaymentOptionUpdate,
FormTemplateUpdate, FormTemplateUpdate,
ImportMapping, ImportMapping,
PlanningNotificationRequest, 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. /** 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é — * Utilisable sans compte connecté (`GET .../form/logo` n'est pas protégé —
* affiché sur `/inscription`). `refreshKey` (ex. `dataUpdatedAt` de * affiché sur `/inscription`). `refreshKey` (ex. `dataUpdatedAt` de
+18
View File
@@ -14,6 +14,8 @@ import type {
DayPartUpdate, DayPartUpdate,
EditionUpdate, EditionUpdate,
FormFieldsSet, FormFieldsSet,
FormPaymentOptionCreate,
FormPaymentOptionUpdate,
FormTemplateUpdate, FormTemplateUpdate,
ImportMapping, ImportMapping,
LoginRequest, LoginRequest,
@@ -296,6 +298,7 @@ export function createHttpApi(baseUrl: string): PlanningApi {
if (body.willing_to_pay_fee !== undefined) { if (body.willing_to_pay_fee !== undefined) {
form.append('willing_to_pay_fee', String(body.willing_to_pay_fee)); 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) { if (body.position_wish_ids) {
form.append('position_wish_ids', JSON.stringify(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); if (body.photo) form.append('photo', body.photo);
return request(`${base(editionId)}/form/public/submit`, { method: 'POST', body: form }); 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: { positionSheets: {
generate: async (editionId, body: PositionSheetsRequest) => { generate: async (editionId, body: PositionSheetsRequest) => {
+11 -2
View File
@@ -9,6 +9,7 @@ import type {
DayPartRead, DayPartRead,
EditionRead, EditionRead,
FormFieldType, FormFieldType,
FormPaymentOptionRead,
PositionCategory, PositionCategory,
PositionRead, PositionRead,
ShiftRead, ShiftRead,
@@ -56,8 +57,10 @@ export interface MockState {
logo: string | null; // data URL logo: string | null; // data URL
logoType: string | null; logoType: string | null;
description: string | null; description: string | null;
yaplaUrl: string | null; sumupMerchantCode: string | null;
sumupApiKey: string | null;
}; };
formPaymentOptions: FormPaymentOptionRead[];
/** volunteerId -> photo (formulaire public), data URL. */ /** volunteerId -> photo (formulaire public), data URL. */
photos: Map<string, string>; photos: Map<string, string>;
seq: number; seq: number;
@@ -273,6 +276,10 @@ export function buildMockState(): MockState {
companion_wish_note: null, companion_wish_note: null,
self_declared_available: false, self_declared_available: false,
willing_to_pay_fee: false, willing_to_pay_fee: false,
fee_payment_label: null,
fee_amount: null,
fee_payment_status: null,
fee_paid_at: null,
created_at: NOW, created_at: NOW,
updated_at: NOW, updated_at: NOW,
}); });
@@ -447,8 +454,10 @@ export function buildMockState(): MockState {
fields: [], fields: [],
}, },
formTemplate: { 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(), photos: new Map(),
seq, seq,
}; };
+107 -9
View File
@@ -20,6 +20,9 @@ import type {
DayPartUpdate, DayPartUpdate,
EditionUpdate, EditionUpdate,
FormFieldsSet, FormFieldsSet,
FormPaymentOptionCreate,
FormPaymentOptionRead,
FormPaymentOptionUpdate,
FormSubmissionResult, FormSubmissionResult,
FormTemplateRead, FormTemplateRead,
FormTemplateUpdate, FormTemplateUpdate,
@@ -122,15 +125,26 @@ const fileToDataUrl = (file: File): Promise<string> =>
reader.readAsDataURL(file); reader.readAsDataURL(file);
}); });
const sumupEnabled = (): boolean =>
Boolean(
state.formTemplate.sumupMerchantCode &&
state.formTemplate.sumupApiKey &&
state.formPaymentOptions.length > 0,
);
const formTemplateRead = (): FormTemplateRead => ({ const formTemplateRead = (): FormTemplateRead => ({
edition_id: state.edition.id, edition_id: state.edition.id,
is_open: state.formTemplate.isOpen, is_open: state.formTemplate.isOpen,
fields: state.formTemplate.fields, fields: state.formTemplate.fields,
has_logo: state.formTemplate.logo !== null, has_logo: state.formTemplate.logo !== null,
description: state.formTemplate.description, 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 // 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 // (réservée au back-end réel) pour que le téléchargement fonctionne en mode
// démonstration. // démonstration.
@@ -856,6 +870,10 @@ export function createMockApi(): PlanningApi {
companion_wish_note: null, companion_wish_note: null,
self_declared_available: false, self_declared_available: false,
willing_to_pay_fee: false, willing_to_pay_fee: false,
fee_payment_label: null,
fee_amount: null,
fee_payment_status: null,
fee_paid_at: null,
created_at: now, created_at: now,
updated_at: now, updated_at: now,
}; };
@@ -1191,6 +1209,10 @@ export function createMockApi(): PlanningApi {
companion_wish_note: null, companion_wish_note: null,
self_declared_available: false, self_declared_available: false,
willing_to_pay_fee: false, willing_to_pay_fee: false,
fee_payment_label: null,
fee_amount: null,
fee_payment_status: null,
fee_paid_at: null,
created_at: now, created_at: now,
updated_at: now, updated_at: now,
}; };
@@ -1484,11 +1506,11 @@ export function createMockApi(): PlanningApi {
setOpen: (_edition, body: FormTemplateUpdate) => { setOpen: (_edition, body: FormTemplateUpdate) => {
if (body.is_open !== undefined) state.formTemplate.isOpen = body.is_open; if (body.is_open !== undefined) state.formTemplate.isOpen = body.is_open;
if (body.description !== undefined) state.formTemplate.description = body.description; if (body.description !== undefined) state.formTemplate.description = body.description;
if (body.yapla_url !== undefined) { if (body.sumup_merchant_code !== undefined) {
if (body.yapla_url && !/^https?:\/\//.test(body.yapla_url)) { state.formTemplate.sumupMerchantCode = body.sumup_merchant_code;
throw new ApiError(422, 'form.invalid_yapla_url'); }
} if (body.sumup_api_key !== undefined) {
state.formTemplate.yaplaUrl = body.yapla_url; state.formTemplate.sumupApiKey = body.sumup_api_key || null;
} }
return delay<FormTemplateRead>(formTemplateRead()); return delay<FormTemplateRead>(formTemplateRead());
}, },
@@ -1518,9 +1540,62 @@ export function createMockApi(): PlanningApi {
: [], : [],
has_logo: state.formTemplate.logo !== null, has_logo: state.formTemplate.logo !== null,
description: state.formTemplate.description, 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<FormPaymentOptionRead[]> => 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<FormPaymentOptionRead>(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<FormPaymentOptionRead>(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) => { submitPublic: async (_edition, body: PublicFormSubmission) => {
if (!state.formTemplate.isOpen) throw new ApiError(422, 'form.closed'); if (!state.formTemplate.isOpen) throw new ApiError(422, 'form.closed');
if (!body.hcaptcha_token) throw new ApiError(422, 'form.captcha_failed'); 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'); 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 now = touch();
const volunteer: VolunteerRead = { const volunteer: VolunteerRead = {
id: `v${state.seq++}`, id: `v${state.seq++}`,
@@ -1557,8 +1638,12 @@ export function createMockApi(): PlanningApi {
: null, : null,
self_declared_available: fields.has('AVAILABLE') ? Boolean(body.available) : false, self_declared_available: fields.has('AVAILABLE') ? Boolean(body.available) : false,
willing_to_pay_fee: fields.has('WILLING_TO_PAY_FEE') 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, : false,
fee_payment_label: null,
fee_amount: null,
fee_payment_status: null,
fee_paid_at: null,
created_at: now, created_at: now,
updated_at: now, updated_at: now,
}; };
@@ -1568,9 +1653,22 @@ export function createMockApi(): PlanningApi {
state.photos.set(volunteer.id, await fileToDataUrl(body.photo)); 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.volunteers.push(volunteer);
state.availability.set(volunteer.id, []); state.availability.set(volunteer.id, []);
return delay<FormSubmissionResult>({ volunteer_id: volunteer.id }); return delay<FormSubmissionResult>({
volunteer_id: volunteer.id,
sumup_checkout_url: checkoutUrl,
});
}, },
}, },
+59 -4
View File
@@ -113,10 +113,20 @@ export interface VolunteerRead {
companion_wish_note: string | null; companion_wish_note: string | null;
self_declared_available: boolean; self_declared_available: boolean;
willing_to_pay_fee: 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; created_at: string;
updated_at: string; updated_at: string;
} }
export type FeePaymentStatus = 'PENDING' | 'PAID' | 'FAILED' | 'EXPIRED';
export interface VolunteerCreate { export interface VolunteerCreate {
first_name: string; first_name: string;
last_name: string; last_name: string;
@@ -573,14 +583,43 @@ export interface FormTemplateRead {
fields: FormFieldType[]; fields: FormFieldType[];
has_logo: boolean; has_logo: boolean;
description: string | null; 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 { export interface FormTemplateUpdate {
is_open?: boolean; is_open?: boolean;
description?: string | null; 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 { export interface PublicPositionOption {
@@ -594,6 +633,14 @@ export interface PublicArtistOption {
performance_date: string; 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. */ /** Tout ce qu'il faut pour afficher le formulaire public — jamais plus. */
export interface PublicFormConfig { export interface PublicFormConfig {
is_open: boolean; is_open: boolean;
@@ -602,11 +649,16 @@ export interface PublicFormConfig {
artists: PublicArtistOption[]; artists: PublicArtistOption[];
has_logo: boolean; has_logo: boolean;
description: string | null; 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 { export interface FormSubmissionResult {
volunteer_id: string; 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 /** Champs du formulaire public — envoyés en `multipart/form-data` (photo
@@ -622,6 +674,9 @@ export interface PublicFormSubmission {
companion_wish_note?: string; companion_wish_note?: string;
available?: boolean; available?: boolean;
willing_to_pay_fee?: 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[]; position_wish_ids?: string[];
artist_wish_ids?: string[]; artist_wish_ids?: string[];
photo?: File | null; photo?: File | null;
+14
View File
@@ -24,6 +24,9 @@ import type {
EditionRead, EditionRead,
EditionUpdate, EditionUpdate,
FormFieldsSet, FormFieldsSet,
FormPaymentOptionCreate,
FormPaymentOptionRead,
FormPaymentOptionUpdate,
FormSubmissionResult, FormSubmissionResult,
FormTemplateRead, FormTemplateRead,
FormTemplateUpdate, FormTemplateUpdate,
@@ -173,6 +176,17 @@ export interface PlanningApi {
getPublicConfig(editionId: string): Promise<PublicFormConfig>; getPublicConfig(editionId: string): Promise<PublicFormConfig>;
submitPublic(editionId: string, body: PublicFormSubmission): Promise<FormSubmissionResult>; submitPublic(editionId: string, body: PublicFormSubmission): Promise<FormSubmissionResult>;
logoBlob(editionId: string): Promise<Blob | null>; logoBlob(editionId: string): Promise<Blob | null>;
/** Types de paiement de la cotisation (COORDINATION uniquement). */
paymentOptions: {
list(editionId: string): Promise<FormPaymentOptionRead[]>;
create(editionId: string, body: FormPaymentOptionCreate): Promise<FormPaymentOptionRead>;
update(
editionId: string,
id: string,
body: FormPaymentOptionUpdate,
): Promise<FormPaymentOptionRead>;
remove(editionId: string, id: string): Promise<void>;
};
}; };
positionSheets: { positionSheets: {
/** Feuilles de poste PDF (une page par poste et par jour). */ /** Feuilles de poste PDF (une page par poste et par jour). */
+23 -7
View File
@@ -381,6 +381,10 @@
"form.field.available": "Case « je suis disponible »", "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.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 »", "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.photo": "Une magnifique photo de toi :",
"form.field.comment": "Commentaire", "form.field.comment": "Commentaire",
"form.link.title": "Lien du formulaire", "form.link.title": "Lien du formulaire",
@@ -401,18 +405,28 @@
"form.customize.logo.uploading": "Envoi…", "form.customize.logo.uploading": "Envoi…",
"form.customize.description": "Texte d'introduction", "form.customize.description": "Texte d'introduction",
"form.customize.description.placeholder": "Un mot de bienvenue affiché en haut du formulaire (facultatif)…", "form.customize.description.placeholder": "Un mot de bienvenue affiché en haut du formulaire (facultatif)…",
"form.customize.yaplaUrl": "Lien de cotisation Yapla", "form.customize.sumup.title": "Paiement de la cotisation (SumUp)",
"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.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.yaplaUrl.placeholder": "https://www.yapla.com/fr/...", "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.title": "Inscription bénévole",
"publicForm.positionWishes.count": "{count} / {max}", "publicForm.positionWishes.count": "{count} / {max}",
"publicForm.artistWishes.none": "— Aucun —", "publicForm.artistWishes.none": "— Aucun —",
"publicForm.field.available": "Je suis bénévole les deux jours (vendredi 30 et samedi 31 oct.)", "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.photo.squareHint": "Recadrée automatiquement en carré.",
"publicForm.yapla.hint": "Le paiement se fait en ligne, via notre page Yapla.", "publicForm.sumup.afterSubmitHint": "Un lien de paiement en ligne (SumUp) te sera proposé juste après l'envoi de ton inscription.",
"publicForm.yapla.cta": "Payer ma cotisation ↗", "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.captcha.disabled": "(Vérification anti-robot non configurée sur cette instance.)",
"publicForm.submitting": "Envoi…", "publicForm.submitting": "Envoi…",
"publicForm.submit": "Envoyer mon inscription", "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_type_not_supported": "Le logo doit être au format PNG ou JPEG.",
"form.logo_empty": "Le logo est vide.", "form.logo_empty": "Le logo est vide.",
"form.logo_too_large": "Le logo est trop volumineux.", "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_position_wishes": "Deux vœux de poste au maximum.",
"form.too_many_artists_same_day": "Un seul artiste souhaité par soir de festival.", "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.", "position_sheets.no_positions": "Aucun poste ne correspond à cette sélection.",
+155 -20
View File
@@ -2,9 +2,13 @@ import { useEffect, useRef, useState } from 'react';
import { ApiError } from '@/api'; import { ApiError } from '@/api';
import { import {
useCreateFormPaymentOption,
useDeleteFormPaymentOption,
useFormLogoUrl, useFormLogoUrl,
useFormPaymentOptions,
useFormTemplate, useFormTemplate,
useSetFormFields, useSetFormFields,
useUpdateFormPaymentOption,
useUpdateFormTemplate, useUpdateFormTemplate,
useUploadFormLogo, useUploadFormLogo,
} from '@/api/hooks'; } from '@/api/hooks';
@@ -60,11 +64,11 @@ const FIELD_HELP_KEY: Record<FormFieldType, string | null> = {
export function FormBuilderPage() { export function FormBuilderPage() {
const template = useFormTemplate(); const template = useFormTemplate();
const setFields = useSetFormFields(); 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. // le statut (pending/success) de l'une ne doit pas déteindre sur l'autre.
const toggleOpen = useUpdateFormTemplate(); const toggleOpen = useUpdateFormTemplate();
const saveDescription = useUpdateFormTemplate(); const saveDescription = useUpdateFormTemplate();
const saveYaplaUrl = useUpdateFormTemplate(); const saveSumUp = useUpdateFormTemplate();
const uploadLogo = useUploadFormLogo(); const uploadLogo = useUploadFormLogo();
const logoUrl = useFormLogoUrl(template.data?.has_logo ?? false, template.dataUpdatedAt); const logoUrl = useFormLogoUrl(template.data?.has_logo ?? false, template.dataUpdatedAt);
const logoInputRef = useRef<HTMLInputElement>(null); const logoInputRef = useRef<HTMLInputElement>(null);
@@ -72,7 +76,8 @@ export function FormBuilderPage() {
const [enabled, setEnabled] = useState<Set<FormFieldType> | null>(null); const [enabled, setEnabled] = useState<Set<FormFieldType> | null>(null);
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [description, setDescription] = useState(''); const [description, setDescription] = useState('');
const [yaplaUrl, setYaplaUrl] = useState(''); const [sumupMerchantCode, setSumupMerchantCode] = useState('');
const [sumupApiKey, setSumupApiKey] = useState('');
useEffect(() => { useEffect(() => {
if (template.data) setEnabled(new Set(template.data.fields)); if (template.data) setEnabled(new Set(template.data.fields));
@@ -81,7 +86,7 @@ export function FormBuilderPage() {
if (template.data) setDescription(template.data.description ?? ''); if (template.data) setDescription(template.data.description ?? '');
}, [template.data]); }, [template.data]);
useEffect(() => { useEffect(() => {
if (template.data) setYaplaUrl(template.data.yapla_url ?? ''); if (template.data) setSumupMerchantCode(template.data.sumup_merchant_code ?? '');
}, [template.data]); }, [template.data]);
if (template.isLoading || !enabled) return <Loading />; if (template.isLoading || !enabled) return <Loading />;
@@ -241,37 +246,167 @@ export function FormBuilderPage() {
</div> </div>
</div> </div>
<div className="flex flex-col gap-2 border-t-2 border-ink p-3"> <div className="flex flex-col gap-3 border-t-2 border-ink p-3">
<div className="flex items-center gap-[6px]"> <div className="flex items-center gap-[6px]">
<FieldLabel>{t('form.customize.yaplaUrl')}</FieldLabel> <FieldLabel>{t('form.customize.sumup.title')}</FieldLabel>
<HelpTip text={t('form.customize.yaplaUrl.help')} /> <HelpTip text={t('form.customize.sumup.help')} />
</div> </div>
<input
type="text" <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
value={yaplaUrl} <label className="block">
onChange={(e) => setYaplaUrl(e.target.value)} <FieldLabel>{t('form.customize.sumup.merchantCode')}</FieldLabel>
placeholder={t('form.customize.yaplaUrl.placeholder')} <input
className="w-full border-2 border-ink bg-cream px-[9px] py-[7px] font-mono text-[11px]" type="text"
/> value={sumupMerchantCode}
onChange={(e) => setSumupMerchantCode(e.target.value)}
placeholder={t('form.customize.sumup.merchantCode.placeholder')}
className="w-full border-2 border-ink bg-cream px-[9px] py-[7px] font-mono text-[11px]"
/>
</label>
<label className="block">
<FieldLabel>
{t('form.customize.sumup.apiKey')}{' '}
{template.data?.sumup_api_key_set ? (
<span className="font-mono text-[9.5px] font-bold text-green">
{t('form.customize.sumup.apiKey.set')}
</span>
) : null}
</FieldLabel>
<input
type="password"
value={sumupApiKey}
onChange={(e) => setSumupApiKey(e.target.value)}
placeholder={t('form.customize.sumup.apiKey.placeholder')}
className="w-full border-2 border-ink bg-cream px-[9px] py-[7px] font-mono text-[11px]"
/>
</label>
</div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Btn <Btn
variant="ghost" variant="ghost"
onClick={() => saveYaplaUrl.mutate({ yapla_url: yaplaUrl.trim() || null })} onClick={() =>
disabled={saveYaplaUrl.isPending} 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')}
</Btn> </Btn>
{saveYaplaUrl.isSuccess ? ( {saveSumUp.isSuccess ? (
<span className="font-mono text-[9.5px] font-bold text-green">{t('form.saved')}</span> <span className="font-mono text-[9.5px] font-bold text-green">{t('form.saved')}</span>
) : null} ) : null}
{saveYaplaUrl.isError ? ( {saveSumUp.isError ? (
<span className="font-mono text-[9.5px] font-bold text-orange-dark"> <span className="font-mono text-[9.5px] font-bold text-orange-dark">
{errorMessage(saveYaplaUrl.error)} {errorMessage(saveSumUp.error)}
</span> </span>
) : null} ) : null}
</div> </div>
<div className="border-t-2 border-ink pt-3">
<FieldLabel>{t('form.customize.paymentOptions.title')}</FieldLabel>
<PaymentOptionsForm />
</div>
</div> </div>
</Panel> </Panel>
</div> </div>
); );
} }
/** 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 <div className="font-mono text-[10px] opacity-60">…</div>;
if (options.error) return <ErrorNote error={options.error} />;
const rows = [...(options.data ?? [])].sort(
(a, b) => a.order - b.order || a.label.localeCompare(b.label),
);
return (
<div className="mt-2 flex flex-col gap-[6px]">
{rows.map((o) => (
<div key={o.id} className="flex items-center gap-1">
<input
defaultValue={o.label}
onBlur={(e) => {
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"
/>
<input
type="number"
min="0"
step="0.01"
defaultValue={o.amount}
onBlur={(e) => {
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]"
/>
<span className="font-mono text-[10px] opacity-60">€</span>
<button
type="button"
onClick={() => remove.mutate(o.id)}
aria-label={t('action.remove')}
className="h-[26px] w-[26px] flex-none cursor-pointer border-2 border-ink bg-cream font-display text-[11px] font-bold hover:bg-orange"
>
✕
</button>
</div>
))}
<div className="mt-1 flex items-center gap-1 border-t-2 border-ink pt-2">
<input
value={label}
onChange={(e) => 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"
/>
<input
type="number"
min="0"
step="0.01"
value={amount}
onChange={(e) => setAmount(e.target.value)}
placeholder="20"
className="w-[90px] border-2 border-ink bg-paper px-1 py-1 font-mono text-[11px]"
/>
<span className="font-mono text-[10px] opacity-60">€</span>
<button
type="button"
disabled={!label.trim() || !(Number(amount) > 0) || create.isPending}
onClick={() =>
create.mutate(
{ label: label.trim(), amount: Number(amount), order: rows.length },
{ onSuccess: () => { setLabel(''); setAmount(''); } },
)
}
className="h-[26px] flex-none cursor-pointer border-2 border-ink bg-ink px-2 font-display text-[10px] font-extrabold uppercase text-cream hover:bg-yellow hover:text-ink disabled:opacity-50"
>
{t('form.customize.paymentOptions.add')}
</button>
</div>
{create.isError ? <ErrorNote error={create.error} /> : null}
{update.isError ? <ErrorNote error={update.error} /> : null}
</div>
);
}
+62 -18
View File
@@ -138,6 +138,7 @@ export function PublicSignupPage() {
const [companionNote, setCompanionNote] = useState(''); const [companionNote, setCompanionNote] = useState('');
const [available, setAvailable] = useState(false); const [available, setAvailable] = useState(false);
const [willingToPayFee, setWillingToPayFee] = useState(false); const [willingToPayFee, setWillingToPayFee] = useState(false);
const [paymentOptionId, setPaymentOptionId] = useState('');
const [positionIds, setPositionIds] = useState<string[]>([]); const [positionIds, setPositionIds] = useState<string[]>([]);
const [artistByDay, setArtistByDay] = useState<Record<string, string>>({}); const [artistByDay, setArtistByDay] = useState<Record<string, string>>({});
const [photo, setPhoto] = useState<File | null>(null); const [photo, setPhoto] = useState<File | null>(null);
@@ -145,6 +146,7 @@ export function PublicSignupPage() {
const [captchaToken, setCaptchaToken] = useState(''); const [captchaToken, setCaptchaToken] = useState('');
const [submitError, setSubmitError] = useState<string | null>(null); const [submitError, setSubmitError] = useState<string | null>(null);
const [volunteerId, setVolunteerId] = useState<string | null>(null); const [volunteerId, setVolunteerId] = useState<string | null>(null);
const [sumupCheckoutUrl, setSumupCheckoutUrl] = useState<string | null>(null);
const artistsByDay = useMemo( const artistsByDay = useMemo(
() => groupArtistsByDay(config.data?.artists ?? []), () => groupArtistsByDay(config.data?.artists ?? []),
@@ -180,7 +182,24 @@ export function PublicSignupPage() {
<FormLogo url={logoUrl} /> <FormLogo url={logoUrl} />
<Panel> <Panel>
<PanelHeader>{t('publicForm.success.title')}</PanelHeader> <PanelHeader>{t('publicForm.success.title')}</PanelHeader>
<p className="p-4 font-mono text-[12px] leading-relaxed">{t('publicForm.success.body')}</p> <div className="flex flex-col gap-3 p-4">
<p className="font-mono text-[12px] leading-relaxed">{t('publicForm.success.body')}</p>
{sumupCheckoutUrl ? (
<div className="flex flex-wrap items-center gap-[10px] border-2 border-ink bg-yellow-soft px-[12px] py-[10px]">
<span className="min-w-0 flex-1 font-mono text-[11px] leading-relaxed">
{t('publicForm.sumup.hint')}
</span>
<a
href={sumupCheckoutUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex flex-none cursor-pointer items-center justify-center gap-2 border-2 border-ink bg-yellow px-3 py-2 font-display text-[11px] font-extrabold uppercase tracking-wide shadow-brutal-sm hover:bg-green hover:text-white"
>
{t('publicForm.sumup.cta')}
</a>
</div>
) : null}
</div>
</Panel> </Panel>
</div> </div>
); );
@@ -238,11 +257,16 @@ export function PublicSignupPage() {
companion_wish_note: fields.has('COMPANION_WISH') ? companionNote : undefined, companion_wish_note: fields.has('COMPANION_WISH') ? companionNote : undefined,
available: fields.has('AVAILABLE') ? available : undefined, available: fields.has('AVAILABLE') ? available : undefined,
willing_to_pay_fee: fields.has('WILLING_TO_PAY_FEE') ? willingToPayFee : 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, position_wish_ids: fields.has('POSITION_WISHES') ? positionIds : undefined,
artist_wish_ids: fields.has('ARTIST_WISHES') ? Object.values(artistByDay) : undefined, artist_wish_ids: fields.has('ARTIST_WISHES') ? Object.values(artistByDay) : undefined,
photo: fields.has('PHOTO') ? photo : undefined, photo: fields.has('PHOTO') ? photo : undefined,
}); });
setVolunteerId(result.volunteer_id); setVolunteerId(result.volunteer_id);
setSumupCheckoutUrl(result.sumup_checkout_url);
} catch (err) { } catch (err) {
setSubmitError(errorMessage(err)); setSubmitError(errorMessage(err));
} }
@@ -374,7 +398,43 @@ export function PublicSignupPage() {
</label> </label>
) : null} ) : null}
{fields.has('WILLING_TO_PAY_FEE') ? ( {fields.has('WILLING_TO_PAY_FEE') && config.data.sumup_enabled ? (
<div>
<FieldLabel>{t('publicForm.field.paymentOption')}</FieldLabel>
<div className="flex flex-col gap-[6px] border-2 border-ink bg-cream p-[9px]">
<label className="flex cursor-pointer items-center gap-[8px] font-mono text-[12px]">
<input
type="radio"
name="payment_option"
checked={paymentOptionId === ''}
onChange={() => setPaymentOptionId('')}
className="h-[15px] w-[15px] accent-ink"
/>
{t('publicForm.paymentOption.none')}
</label>
{config.data.payment_options.map((o) => (
<label
key={o.id}
className="flex cursor-pointer items-center gap-[8px] font-mono text-[12px]"
>
<input
type="radio"
name="payment_option"
checked={paymentOptionId === o.id}
onChange={() => setPaymentOptionId(o.id)}
className="h-[15px] w-[15px] accent-ink"
/>
{o.label} — {o.amount} €
</label>
))}
</div>
{paymentOptionId ? (
<p className="mt-[6px] font-mono text-[10.5px] opacity-70">
{t('publicForm.sumup.afterSubmitHint')}
</p>
) : null}
</div>
) : fields.has('WILLING_TO_PAY_FEE') ? (
<label className="flex cursor-pointer items-center gap-[8px]"> <label className="flex cursor-pointer items-center gap-[8px]">
<input <input
type="checkbox" type="checkbox"
@@ -386,22 +446,6 @@ export function PublicSignupPage() {
</label> </label>
) : null} ) : null}
{fields.has('WILLING_TO_PAY_FEE') && config.data.yapla_url ? (
<div className="flex flex-wrap items-center gap-[10px] border-2 border-ink bg-yellow-soft px-[12px] py-[10px]">
<span className="min-w-0 flex-1 font-mono text-[11px] leading-relaxed">
{t('publicForm.yapla.hint')}
</span>
<a
href={config.data.yapla_url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex flex-none cursor-pointer items-center justify-center gap-2 border-2 border-ink bg-yellow px-3 py-2 font-display text-[11px] font-extrabold uppercase tracking-wide shadow-brutal-sm hover:bg-green hover:text-white"
>
{t('publicForm.yapla.cta')}
</a>
</div>
) : null}
{fields.has('PHOTO') ? ( {fields.has('PHOTO') ? (
<label className="block"> <label className="block">
<FieldLabel>{t('form.field.photo')}</FieldLabel> <FieldLabel>{t('form.field.photo')}</FieldLabel>
@@ -160,6 +160,13 @@ function VolunteerAccessSection({
); );
} }
const FEE_STATUS_LABEL_KEY: Record<NonNullable<VolunteerRead['fee_payment_status']>, string> = {
PENDING: 'config.vol.feeStatus.pending',
PAID: 'config.vol.feeStatus.paid',
FAILED: 'config.vol.feeStatus.failed',
EXPIRED: 'config.vol.feeStatus.expired',
};
/** /**
* Réponses du formulaire public d'inscription (voir `app/api/forms.py`) — * Réponses du formulaire public d'inscription (voir `app/api/forms.py`) —
* note de compagnon en texte libre (à résoudre manuellement en vrai vœu via * note de compagnon en texte libre (à résoudre manuellement en vrai vœu via
@@ -183,6 +190,10 @@ function PublicFormAnswers({
volunteer.willing_to_pay_fee; volunteer.willing_to_pay_fee;
if (!hasAnything) return null; if (!hasAnything) return null;
const feeStatusKey = volunteer.fee_payment_status
? FEE_STATUS_LABEL_KEY[volunteer.fee_payment_status]
: null;
return ( return (
<div className="flex flex-col gap-2 border-2 border-ink bg-sand p-[9px]"> <div className="flex flex-col gap-2 border-2 border-ink bg-sand p-[9px]">
<FieldLabel> <FieldLabel>
@@ -228,6 +239,12 @@ function PublicFormAnswers({
/> />
<span className="font-mono text-[11px]">{t('form.field.willingToPayFee')}</span> <span className="font-mono text-[11px]">{t('form.field.willingToPayFee')}</span>
</label> </label>
{feeStatusKey ? (
<span className="border-2 border-ink bg-cream px-[8px] py-[3px] font-mono text-[10.5px] font-bold">
{volunteer.fee_payment_label ? `${volunteer.fee_payment_label} (${volunteer.fee_amount} €) — ` : ''}
{t(feeStatusKey)}
</span>
) : null}
</div> </div>
</div> </div>
); );