Compare commits
2
Commits
26a10cd595
...
0bd38a6465
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0bd38a6465 | ||
|
|
0be5daa89c |
@@ -1,6 +1,5 @@
|
||||
# CI/CD Gitea Actions : tests à chaque push, puis sur `main` seulement,
|
||||
# build des images (registre de conteneurs intégré à Gitea) et déploiement
|
||||
# SSH sur le VPS.
|
||||
# CI/CD Gitea Actions : à chaque push sur `main`, build des images
|
||||
# (registre de conteneurs intégré à Gitea) puis déploiement SSH sur le VPS.
|
||||
#
|
||||
# Nécessite un runner Gitea Actions (act_runner, exécuteur "docker") attaché
|
||||
# au dépôt ou à l'organisation.
|
||||
@@ -27,72 +26,14 @@ name: ci-cd
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ['**']
|
||||
branches: [main]
|
||||
|
||||
concurrency:
|
||||
group: ci-cd-${{ gitea.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
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:
|
||||
needs: backend-test
|
||||
if: gitea.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
container: docker:27
|
||||
steps:
|
||||
@@ -109,8 +50,7 @@ jobs:
|
||||
docker push "$IMAGE:latest"
|
||||
|
||||
build-frontend:
|
||||
needs: frontend-test
|
||||
if: gitea.ref == 'refs/heads/main'
|
||||
needs: build-backend # séquentiel pour ne pas saturer le CPU
|
||||
runs-on: ubuntu-latest
|
||||
container: docker:27
|
||||
steps:
|
||||
@@ -133,7 +73,6 @@ jobs:
|
||||
|
||||
deploy:
|
||||
needs: [build-backend, build-frontend]
|
||||
if: gitea.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
container: alpine:3.20
|
||||
steps:
|
||||
|
||||
@@ -80,9 +80,11 @@ les tables sauf les tables d'association.
|
||||
forcément encore en base au moment de l'inscription, donc pas de lien
|
||||
direct vers un `VolunteerCompanionWish` ; la coordination la résout à la
|
||||
main depuis la fiche une fois les deux bénévoles inscrits),
|
||||
`self_declared_available`/`willing_to_pay_fee` (cases à cocher **purement
|
||||
informatives**, sans lien avec `Availability` ni aucun workflow de
|
||||
paiement).
|
||||
`self_declared_available` (case à cocher **purement informative**, sans
|
||||
lien avec `Availability`) et `willing_to_pay_fee` (« je participe à la
|
||||
cotisation » — déclenche, lui, un vrai paiement : voir `fee_payment_label`/
|
||||
`fee_amount`/`fee_checkout_id`/`fee_payment_status`/`fee_paid_at` et le
|
||||
paiement SumUp plus bas).
|
||||
- **`Artist`** : programmation de l'édition (`name`, `performance_date`,
|
||||
`start_time`, `end_time` — `end_time <= start_time` ⇒ le passage finit le
|
||||
lendemain). Unique (`edition_id`, `name`, `performance_date`). La
|
||||
@@ -194,7 +196,22 @@ les tables sauf les tables d'association.
|
||||
affectation n'apparaît pas (rien à imprimer).
|
||||
- **`FormTemplate`** : configuration du formulaire public d'inscription
|
||||
d'une édition (une ligne, créée à la demande). `is_open` coupe les
|
||||
nouvelles soumissions sans effacer la configuration.
|
||||
nouvelles soumissions sans effacer la configuration. `sumup_merchant_code`/
|
||||
`sumup_api_key` sont les identifiants du compte marchand SumUp pour le
|
||||
paiement de la cotisation (`app/sumup/`) — `sumup_api_key` est un secret,
|
||||
jamais renvoyé par l'API (`FormTemplateRead.sumup_api_key_set: bool` à la
|
||||
place ; chaîne vide en écriture ⇒ l'efface).
|
||||
- **`FormPaymentOption`** : un type de paiement de la cotisation (ex.
|
||||
« Cotisation classique », « Mécénat ») — `label` + `amount` (montant
|
||||
fixe), `order`. Liste libre par édition, autant que la coordination veut
|
||||
(comme les créneaux types `DayPart`), gérée depuis l'écran « Inscriptions »
|
||||
(bloc « Types de paiement »). CRUD `GET/POST/PATCH/DELETE
|
||||
/editions/{id}/form/payment-options` (COORDINATION). Unique
|
||||
(`edition_id`, `label`) — 409 `form.duplicate_payment_option_label` sinon ;
|
||||
montant `> 0` requis — 422 `form.invalid_sumup_amount` sinon. Le bénévole
|
||||
en choisit au plus un à l'inscription (voir `Paiement de la cotisation`
|
||||
plus bas) ; supprimer une option n'affecte jamais les bénévoles qui
|
||||
l'avaient déjà choisie (copie figée sur `Volunteer`).
|
||||
- **`FormField`** : un champ activé sur le formulaire (`field_type` ∈
|
||||
`EMAIL` / `PHONE` / `POSITION_WISHES` / `ARTIST_WISHES` / `COMPANION_WISH`
|
||||
/ `AVAILABLE` / `WILLING_TO_PAY_FEE` / `PHOTO` / `COMMENT`, au plus un par
|
||||
@@ -205,14 +222,45 @@ les tables sauf les tables d'association.
|
||||
limite) : au plus **deux** vœux de poste, et au plus **un** artiste
|
||||
souhaité par jour de festival (`app/api/forms.py:_check_one_artist_wish_per_day`).
|
||||
`GET/PUT/PATCH /editions/{id}/form[...]` (COORDINATION) configurent ;
|
||||
`GET /editions/{id}/form/public` et `POST .../form/public/submit` sont les
|
||||
**deux seuls endpoints de toute l'API sans authentification** — protégés
|
||||
par hCaptcha (`app/forms/captcha.py`, `HCAPTCHA_SECRET_KEY` ; vérification
|
||||
`GET /editions/{id}/form/public`, `POST .../form/public/submit` et
|
||||
`POST .../form/public/sumup-webhook` sont les **trois seuls endpoints de
|
||||
toute l'API sans authentification** — les deux premiers protégés par
|
||||
hCaptcha (`app/forms/captcha.py`, `HCAPTCHA_SECRET_KEY` ; vérification
|
||||
désactivée si absente, comme `EMAIL_ENABLED=false`) et un filet de
|
||||
sécurité global par édition (`app/auth/rate_limit.py:form_submission_limiter`,
|
||||
hCaptcha reste la défense principale). Le bénévole créé par une soumission
|
||||
apparaît immédiatement dans la liste de la coordination, comme n'importe
|
||||
quel autre bénévole.
|
||||
hCaptcha reste la défense principale) ; le webhook est appelé par SumUp
|
||||
lui-même, donc sans hCaptcha possible — voir plus bas. Le bénévole créé
|
||||
par une soumission apparaît immédiatement dans la liste de la
|
||||
coordination, comme n'importe quel autre bénévole.
|
||||
- **Paiement de la cotisation (SumUp)** : si le bénévole choisit un type de
|
||||
paiement (`FormPaymentOption`, ci-dessus — le formulaire public
|
||||
n'affiche ce choix, à la place de la case `WILLING_TO_PAY_FEE` toute
|
||||
seule, que si `sumup_enabled` sur `PublicFormConfig` est vrai, c.-à-d.
|
||||
identifiants SumUp renseignés **et** au moins un type de paiement
|
||||
configuré ; sinon la case reste une simple déclaration, comme avant SumUp)
|
||||
et que l'édition a configuré ses identifiants, la soumission crée un
|
||||
checkout hébergé (`app/sumup/client.py:create_checkout`, API SumUp
|
||||
`POST /v0.1/checkouts`, `hosted_checkout.enabled=true`, montant et
|
||||
libellé de l'option choisie) et renvoie son URL de paiement dans
|
||||
`FormSubmissionResult.sumup_checkout_url` (le bénévole y est redirigé
|
||||
juste après son inscription — pas avant, contrairement à l'ancien lien
|
||||
Yapla statique). Le libellé et le montant de l'option choisie sont
|
||||
recopiés sur `Volunteer.fee_payment_label`/`fee_amount` **au moment du
|
||||
choix** — une copie figée, jamais recalculée si l'option est éditée ou
|
||||
supprimée ensuite (`FormPaymentOption` reste une pure configuration,
|
||||
jamais une référence vivante depuis l'historique des bénévoles).
|
||||
`Volunteer.fee_checkout_id` (identifiant SumUp, jamais exposé) et
|
||||
`fee_payment_status` (`PENDING`/`PAID`/`FAILED`/`EXPIRED`, `NULL` tant
|
||||
qu'aucun checkout n'a été créé) suivent l'état ; `fee_paid_at` est posé à
|
||||
la première confirmation `PAID`. Best effort, comme l'envoi d'emails : un
|
||||
échec de l'appel API SumUp (réseau, identifiants invalides…) ne bloque
|
||||
jamais l'inscription. Le statut est tenu à jour par
|
||||
`POST .../form/public/sumup-webhook` (`event_type:
|
||||
CHECKOUT_STATUS_CHANGED`, `id`) : le payload n'étant qu'un identifiant,
|
||||
l'app relit toujours l'état réel via l'API (`get_checkout`) avant de
|
||||
l'appliquer, jamais fait confiance au seul contenu du webhook. Visible par
|
||||
la coordination sur la fiche du bénévole (`ConfigVolunteers.tsx`, libellé
|
||||
+ montant + statut), en lecture seule (pas de bascule manuelle).
|
||||
|
||||
Conventions techniques :
|
||||
|
||||
@@ -537,6 +585,7 @@ l'étape 21.)*
|
||||
│ │ ├── badges/ # génération PDF des planches (pdf.py, ReportLab)
|
||||
│ │ ├── sheets/ # génération PDF des feuilles de poste (pdf.py)
|
||||
│ │ ├── forms/ # hCaptcha (captcha.py) — voir api/forms.py
|
||||
│ │ ├── sumup/ # client API SumUp (client.py) — paiement de la cotisation
|
||||
│ │ ├── bootstrap.py # instance vierge : édition + 1er compte
|
||||
│ │ ├── seed.py # jeu de démonstration (python -m app.seed)
|
||||
│ │ ├── i18n/
|
||||
@@ -651,6 +700,7 @@ Aucune dépendance locale requise hors Docker.
|
||||
| 22 | Feuilles de poste (PDF, complément des badges) : une page par poste et par jour avec le référent, les créneaux, les bénévoles affectés et leur téléphone — bouton depuis la grille des besoins (COORDINATION) ; photo du bénévole affichée sur son écran et sur l'écran référent (en face du nom de chaque membre de l'équipe) | à valider |
|
||||
| 23 | Créneaux : plus de génération automatique à la création d'un poste — création manuelle à horaire libre (`ConfigGrid.tsx`), génération depuis les créneaux types conservée en option (« Générer les créneaux ») ; grille des besoins redessinée en vrai axe des temps (une colonne = 30 min, bornée à la plage utilisée par jour, cases fusionnées pour chaque créneau — `buildTimeGrid`/`placeInTimeGrid`) | à valider |
|
||||
| 24 | Corrections de stabilité de la grille des besoins (suite à l'étape 23) : positionnement explicite (`gridColumn`/`gridRow`) de chaque cellule (un poste aux créneaux non alignés sur 30 min ne décale plus les postes suivants) ; hiérarchie de `z-index` sans égalité entre les en-têtes figés et isolation de l'infobulle de survol dans son propre contexte d'empilement (ne recouvrait plus la ligne « Affectés / idéal » pendant un défilement) | à valider |
|
||||
| 25 | Paiement de la cotisation via l'API SumUp (remplace le lien statique Yapla) : plusieurs types de paiement configurables par édition (`FormPaymentOption` — libellé + montant, ex. « Cotisation classique », « Mécénat »), le bénévole en choisit un à l'inscription, checkout hébergé créé à la soumission si l'édition a configuré ses identifiants SumUp, lien de paiement affiché juste après l'inscription, statut (`PENDING`/`PAID`/`FAILED`/`EXPIRED`) tenu à jour par un webhook SumUp et visible (libellé + montant + statut) sur la fiche du bénévole | à valider |
|
||||
|
||||
**Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur
|
||||
de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans +
|
||||
|
||||
+241
-15
@@ -2,11 +2,12 @@
|
||||
|
||||
Deux familles d'endpoints sous le même routeur :
|
||||
- configuration (COORDINATION) : quels champs sont activés, formulaire
|
||||
ouvert ou fermé ;
|
||||
- publics (**aucune authentification** — les seuls endpoints de toute
|
||||
l'API dans ce cas) : consultation de la configuration et soumission,
|
||||
protégés par hCaptcha (`app/forms/captcha.py`) et un filet de sécurité
|
||||
global (`app/auth/rate_limit.py:form_submission_limiter`).
|
||||
ouvert ou fermé, identifiants SumUp de paiement de la cotisation ;
|
||||
- publics (**aucune authentification**) : consultation de la configuration
|
||||
et soumission (protégées par hCaptcha, `app/forms/captcha.py`, et un filet
|
||||
de sécurité global, `app/auth/rate_limit.py:form_submission_limiter`), et
|
||||
le webhook SumUp (`/public/sumup-webhook`, appelé par SumUp lui-même — pas
|
||||
de hCaptcha possible serveur à serveur, voir `sumup_webhook` plus bas).
|
||||
|
||||
Nom et prénom sont toujours collectés (indispensables à la création du
|
||||
bénévole) : ils ne font pas partie des champs activables, contrairement aux
|
||||
@@ -20,6 +21,7 @@ coordination, faute de savoir si la personne visée est déjà inscrite (voir
|
||||
CLAUDE.md § 2, `Volunteer`)."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
from fastapi import (
|
||||
@@ -33,33 +35,46 @@ from fastapi import (
|
||||
status,
|
||||
)
|
||||
from sqlalchemy import delete
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.api._common import set_artists, set_wished_positions
|
||||
from app.api._common import owned_or_404, set_artists, set_wished_positions
|
||||
from app.api.deps import get_edition
|
||||
from app.api.schemas import (
|
||||
FormFieldsSet,
|
||||
FormPaymentOptionCreate,
|
||||
FormPaymentOptionRead,
|
||||
FormPaymentOptionUpdate,
|
||||
FormSubmissionResult,
|
||||
FormTemplateRead,
|
||||
FormTemplateUpdate,
|
||||
PublicArtistOption,
|
||||
PublicFormConfig,
|
||||
PublicPaymentOption,
|
||||
PublicPositionOption,
|
||||
SumUpWebhookPayload,
|
||||
)
|
||||
from app.auth.deps import require_coordinator
|
||||
from app.auth.rate_limit import form_submission_limiter
|
||||
from app.config import get_settings
|
||||
from app.db import get_session
|
||||
from app.forms import verify_hcaptcha
|
||||
from app.models import (
|
||||
Account,
|
||||
Artist,
|
||||
Edition,
|
||||
FeePaymentStatus,
|
||||
FormField,
|
||||
FormFieldType,
|
||||
FormPaymentOption,
|
||||
FormTemplate,
|
||||
Position,
|
||||
Volunteer,
|
||||
local_now,
|
||||
)
|
||||
from app.sumup import create_checkout, get_checkout
|
||||
|
||||
logger = logging.getLogger("app.forms")
|
||||
|
||||
router = APIRouter(prefix="/editions/{edition_id}/form", tags=["form"])
|
||||
|
||||
@@ -95,6 +110,20 @@ def _enabled_fields(session: Session, edition_id: uuid.UUID) -> list[FormFieldTy
|
||||
)
|
||||
|
||||
|
||||
def _payment_options(session: Session, edition_id: uuid.UUID) -> list[FormPaymentOption]:
|
||||
return list(
|
||||
session.exec(
|
||||
select(FormPaymentOption)
|
||||
.where(FormPaymentOption.edition_id == edition_id)
|
||||
.order_by(FormPaymentOption.order, FormPaymentOption.label)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _sumup_enabled(template: FormTemplate, options: list[FormPaymentOption]) -> bool:
|
||||
return bool(template.sumup_merchant_code and template.sumup_api_key and options)
|
||||
|
||||
|
||||
def _template_read(template: FormTemplate, fields: list[FormFieldType]) -> FormTemplateRead:
|
||||
return FormTemplateRead(
|
||||
edition_id=template.edition_id,
|
||||
@@ -102,7 +131,8 @@ def _template_read(template: FormTemplate, fields: list[FormFieldType]) -> FormT
|
||||
fields=fields,
|
||||
has_logo=template.logo is not None,
|
||||
description=template.description,
|
||||
yapla_url=template.yapla_url,
|
||||
sumup_merchant_code=template.sumup_merchant_code,
|
||||
sumup_api_key_set=bool(template.sumup_api_key),
|
||||
)
|
||||
|
||||
|
||||
@@ -140,11 +170,8 @@ def update_form(
|
||||
) -> FormTemplateRead:
|
||||
template = _get_or_create_template(session, edition.id)
|
||||
changes = payload.model_dump(exclude_unset=True)
|
||||
if changes.get("yapla_url"):
|
||||
if not changes["yapla_url"].startswith(("http://", "https://")):
|
||||
raise HTTPException(
|
||||
status_code=422, detail={"code": "form.invalid_yapla_url"}
|
||||
)
|
||||
if changes.get("sumup_api_key") == "":
|
||||
changes["sumup_api_key"] = None
|
||||
for field, value in changes.items():
|
||||
setattr(template, field, value)
|
||||
session.add(template)
|
||||
@@ -152,6 +179,96 @@ def update_form(
|
||||
return _template_read(template, _enabled_fields(session, edition.id))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Types de paiement (COORDINATION) — voir `FormPaymentOption` #
|
||||
# --------------------------------------------------------------------------- #
|
||||
@router.get("/payment-options", response_model=list[FormPaymentOptionRead])
|
||||
def list_payment_options(
|
||||
edition: Edition = Depends(get_edition),
|
||||
_guard: Account = Depends(require_coordinator),
|
||||
session: Session = Depends(get_session),
|
||||
) -> list[FormPaymentOption]:
|
||||
return _payment_options(session, edition.id)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/payment-options",
|
||||
response_model=FormPaymentOptionRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def create_payment_option(
|
||||
payload: FormPaymentOptionCreate,
|
||||
edition: Edition = Depends(get_edition),
|
||||
_guard: Account = Depends(require_coordinator),
|
||||
session: Session = Depends(get_session),
|
||||
) -> FormPaymentOption:
|
||||
if payload.amount <= 0:
|
||||
raise HTTPException(
|
||||
status_code=422, detail={"code": "form.invalid_sumup_amount"}
|
||||
)
|
||||
option = FormPaymentOption(
|
||||
edition_id=edition.id,
|
||||
label=payload.label.strip(),
|
||||
amount=payload.amount,
|
||||
order=payload.order,
|
||||
)
|
||||
session.add(option)
|
||||
try:
|
||||
session.commit()
|
||||
except IntegrityError as exc:
|
||||
session.rollback()
|
||||
raise HTTPException(
|
||||
status_code=409, detail={"code": "form.duplicate_payment_option_label"}
|
||||
) from exc
|
||||
session.refresh(option)
|
||||
return option
|
||||
|
||||
|
||||
@router.patch("/payment-options/{option_id}", response_model=FormPaymentOptionRead)
|
||||
def update_payment_option(
|
||||
option_id: uuid.UUID,
|
||||
payload: FormPaymentOptionUpdate,
|
||||
edition: Edition = Depends(get_edition),
|
||||
_guard: Account = Depends(require_coordinator),
|
||||
session: Session = Depends(get_session),
|
||||
) -> FormPaymentOption:
|
||||
option = owned_or_404(session.get(FormPaymentOption, option_id), edition.id)
|
||||
changes = payload.model_dump(exclude_unset=True)
|
||||
if changes.get("amount") is not None and changes["amount"] <= 0:
|
||||
raise HTTPException(
|
||||
status_code=422, detail={"code": "form.invalid_sumup_amount"}
|
||||
)
|
||||
if changes.get("label") is not None:
|
||||
changes["label"] = changes["label"].strip()
|
||||
for field, value in changes.items():
|
||||
setattr(option, field, value)
|
||||
session.add(option)
|
||||
try:
|
||||
session.commit()
|
||||
except IntegrityError as exc:
|
||||
session.rollback()
|
||||
raise HTTPException(
|
||||
status_code=409, detail={"code": "form.duplicate_payment_option_label"}
|
||||
) from exc
|
||||
session.refresh(option)
|
||||
return option
|
||||
|
||||
|
||||
@router.delete("/payment-options/{option_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_payment_option(
|
||||
option_id: uuid.UUID,
|
||||
edition: Edition = Depends(get_edition),
|
||||
_guard: Account = Depends(require_coordinator),
|
||||
session: Session = Depends(get_session),
|
||||
) -> Response:
|
||||
option = owned_or_404(session.get(FormPaymentOption, option_id), edition.id)
|
||||
# Les bénévoles ayant déjà choisi cette option gardent leur copie figée
|
||||
# (`Volunteer.fee_payment_label`/`fee_amount`) — rien à faire côté eux.
|
||||
session.delete(option)
|
||||
session.commit()
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
async def _read_logo(file: UploadFile) -> tuple[bytes, str]:
|
||||
content_type = file.content_type or ""
|
||||
if content_type not in _ALLOWED_PHOTO_TYPES:
|
||||
@@ -233,6 +350,7 @@ def get_public_form(
|
||||
)
|
||||
]
|
||||
|
||||
options = _payment_options(session, edition.id)
|
||||
return PublicFormConfig(
|
||||
is_open=template.is_open,
|
||||
fields=fields,
|
||||
@@ -240,7 +358,10 @@ def get_public_form(
|
||||
artists=artists,
|
||||
has_logo=template.logo is not None,
|
||||
description=template.description,
|
||||
yapla_url=template.yapla_url,
|
||||
sumup_enabled=_sumup_enabled(template, options),
|
||||
payment_options=[
|
||||
PublicPaymentOption(id=o.id, label=o.label, amount=o.amount) for o in options
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@@ -295,6 +416,7 @@ async def submit_public_form(
|
||||
companion_wish_note: str | None = Form(None),
|
||||
available: bool = Form(False),
|
||||
willing_to_pay_fee: bool = Form(False),
|
||||
payment_option_id: str | None = Form(None),
|
||||
position_wish_ids: str | None = Form(None),
|
||||
artist_wish_ids: str | None = Form(None),
|
||||
photo: UploadFile | None = File(None),
|
||||
@@ -335,6 +457,20 @@ async def submit_public_form(
|
||||
if artist_ids:
|
||||
_check_one_artist_wish_per_day(session, edition.id, artist_ids)
|
||||
|
||||
payment_option: FormPaymentOption | None = None
|
||||
if FormFieldType.WILLING_TO_PAY_FEE in fields and payment_option_id:
|
||||
try:
|
||||
option_uuid = uuid.UUID(payment_option_id)
|
||||
except ValueError:
|
||||
option_uuid = None
|
||||
payment_option = (
|
||||
session.get(FormPaymentOption, option_uuid) if option_uuid else None
|
||||
)
|
||||
if payment_option is None or payment_option.edition_id != edition.id:
|
||||
raise HTTPException(
|
||||
status_code=422, detail={"code": "form.unknown_payment_option"}
|
||||
)
|
||||
|
||||
volunteer = Volunteer(
|
||||
edition_id=edition.id,
|
||||
first_name=first_name.strip().title(),
|
||||
@@ -352,8 +488,13 @@ async def submit_public_form(
|
||||
self_declared_available=(
|
||||
available if FormFieldType.AVAILABLE in fields else False
|
||||
),
|
||||
# Choisir un type de paiement vaut « je participe » même sans cocher
|
||||
# la case (le formulaire public n'affiche d'ailleurs plus qu'un choix
|
||||
# unique dès qu'au moins un type est configuré, voir PublicSignupPage).
|
||||
willing_to_pay_fee=(
|
||||
willing_to_pay_fee if FormFieldType.WILLING_TO_PAY_FEE in fields else False
|
||||
(willing_to_pay_fee or payment_option is not None)
|
||||
if FormFieldType.WILLING_TO_PAY_FEE in fields
|
||||
else False
|
||||
),
|
||||
)
|
||||
|
||||
@@ -382,4 +523,89 @@ async def submit_public_form(
|
||||
|
||||
session.commit()
|
||||
session.refresh(volunteer)
|
||||
return FormSubmissionResult(volunteer_id=volunteer.id)
|
||||
|
||||
checkout_url = None
|
||||
if (
|
||||
payment_option is not None
|
||||
and template.sumup_merchant_code
|
||||
and template.sumup_api_key
|
||||
):
|
||||
checkout_url = _create_fee_checkout(
|
||||
session, edition, template, volunteer, payment_option
|
||||
)
|
||||
|
||||
return FormSubmissionResult(volunteer_id=volunteer.id, sumup_checkout_url=checkout_url)
|
||||
|
||||
|
||||
def _create_fee_checkout(
|
||||
session: Session,
|
||||
edition: Edition,
|
||||
template: FormTemplate,
|
||||
volunteer: Volunteer,
|
||||
option: FormPaymentOption,
|
||||
) -> str | None:
|
||||
"""Crée le checkout SumUp du type de paiement choisi — best effort (comme
|
||||
l'envoi d'emails) : un échec de l'appel API ne remet jamais en cause
|
||||
l'inscription déjà enregistrée. `option.label`/`option.amount` sont
|
||||
recopiés sur le bénévole (`fee_payment_label`/`fee_amount`) avant même
|
||||
l'appel, pour garder une trace du choix même si l'appel échoue."""
|
||||
volunteer.fee_payment_label = option.label
|
||||
volunteer.fee_amount = option.amount
|
||||
|
||||
base = get_settings().public_base_url.rstrip("/")
|
||||
checkout = create_checkout(
|
||||
api_key=template.sumup_api_key,
|
||||
merchant_code=template.sumup_merchant_code,
|
||||
amount=option.amount,
|
||||
currency="EUR",
|
||||
checkout_reference=str(volunteer.id),
|
||||
description=f"{option.label} — édition {edition.year}",
|
||||
return_url=f"{base}/api/editions/{edition.id}/form/public/sumup-webhook",
|
||||
redirect_url=f"{base}/inscription?paiement=ok",
|
||||
)
|
||||
if checkout is not None:
|
||||
volunteer.fee_checkout_id = checkout.id
|
||||
volunteer.fee_payment_status = FeePaymentStatus.PENDING
|
||||
session.add(volunteer)
|
||||
session.commit()
|
||||
return checkout.hosted_checkout_url if checkout is not None else None
|
||||
|
||||
|
||||
@router.post("/public/sumup-webhook", status_code=status.HTTP_200_OK)
|
||||
def sumup_webhook(
|
||||
payload: SumUpWebhookPayload,
|
||||
edition: Edition = Depends(get_edition),
|
||||
session: Session = Depends(get_session),
|
||||
) -> Response:
|
||||
"""Appelé par SumUp (aucune authentification possible côté SumUp) à
|
||||
chaque changement de statut d'un checkout. Le payload ne donne qu'un
|
||||
identifiant : jamais fait confiance sans relire l'état réel via l'API
|
||||
(voir `app/sumup/client.py:get_checkout`). Répond toujours `200`, y
|
||||
compris sur un événement inconnu — SumUp réessaie sinon inutilement."""
|
||||
template = _get_or_create_template(session, edition.id)
|
||||
if template.sumup_api_key:
|
||||
checkout = get_checkout(api_key=template.sumup_api_key, checkout_id=payload.id)
|
||||
volunteer = (
|
||||
session.exec(
|
||||
select(Volunteer).where(
|
||||
Volunteer.edition_id == edition.id,
|
||||
Volunteer.fee_checkout_id == payload.id,
|
||||
)
|
||||
).first()
|
||||
if checkout is not None
|
||||
else None
|
||||
)
|
||||
if checkout is not None and volunteer is not None:
|
||||
try:
|
||||
new_status = FeePaymentStatus(checkout.status)
|
||||
except ValueError:
|
||||
new_status = None
|
||||
if new_status is not None:
|
||||
volunteer.fee_payment_status = new_status
|
||||
if new_status == FeePaymentStatus.PAID and volunteer.fee_paid_at is None:
|
||||
volunteer.fee_paid_at = local_now()
|
||||
session.add(volunteer)
|
||||
session.commit()
|
||||
elif checkout is None:
|
||||
logger.warning("Webhook SumUp : relecture du checkout impossible.")
|
||||
return Response(status_code=status.HTTP_200_OK)
|
||||
|
||||
@@ -9,6 +9,7 @@ from app.models import (
|
||||
AssignmentSource,
|
||||
BadgeAlign,
|
||||
BadgeFieldType,
|
||||
FeePaymentStatus,
|
||||
FormFieldType,
|
||||
PositionCategory,
|
||||
Role,
|
||||
@@ -92,6 +93,15 @@ class VolunteerRead(ApiModel):
|
||||
companion_wish_note: str | None
|
||||
self_declared_available: bool
|
||||
willing_to_pay_fee: bool
|
||||
# Paiement de la cotisation via SumUp (voir CLAUDE.md § 2, `Volunteer`) —
|
||||
# `fee_payment_label`/`fee_amount` : copie figée du type de paiement
|
||||
# choisi (pas la config actuelle, qui peut avoir changé depuis).
|
||||
# `fee_payment_status` reste `None` tant qu'aucun checkout n'a jamais été
|
||||
# créé pour ce bénévole.
|
||||
fee_payment_label: str | None
|
||||
fee_amount: float | None
|
||||
fee_payment_status: FeePaymentStatus | None
|
||||
fee_paid_at: dt.datetime | None
|
||||
created_at: dt.datetime
|
||||
updated_at: dt.datetime
|
||||
|
||||
@@ -493,16 +503,43 @@ class FormTemplateRead(ApiModel):
|
||||
fields: list[FormFieldType]
|
||||
has_logo: bool
|
||||
description: str | None
|
||||
yapla_url: str | None
|
||||
sumup_merchant_code: str | None
|
||||
# `sumup_api_key` (secret) n'est jamais renvoyé — seulement s'il est
|
||||
# défini, comme `has_logo` pour le logo.
|
||||
sumup_api_key_set: bool
|
||||
|
||||
|
||||
class FormTemplateUpdate(ApiModel):
|
||||
"""`None`/absent = inchangé pour chaque champ (`exclude_unset`) — permet
|
||||
de changer `is_open` sans toucher `description`/`yapla_url`, etc."""
|
||||
de changer `is_open` sans toucher `description`/`sumup_*`, etc. Chaîne
|
||||
vide sur `sumup_api_key` = efface le secret enregistré."""
|
||||
|
||||
is_open: bool | None = None
|
||||
description: str | None = None
|
||||
yapla_url: str | None = None
|
||||
sumup_merchant_code: str | None = None
|
||||
sumup_api_key: str | None = None
|
||||
|
||||
|
||||
class FormPaymentOptionRead(ApiModel):
|
||||
id: uuid.UUID
|
||||
edition_id: uuid.UUID
|
||||
label: str
|
||||
amount: float
|
||||
order: int
|
||||
created_at: dt.datetime
|
||||
updated_at: dt.datetime
|
||||
|
||||
|
||||
class FormPaymentOptionCreate(ApiModel):
|
||||
label: str
|
||||
amount: float
|
||||
order: int = 0
|
||||
|
||||
|
||||
class FormPaymentOptionUpdate(ApiModel):
|
||||
label: str | None = None
|
||||
amount: float | None = None
|
||||
order: int | None = None
|
||||
|
||||
|
||||
class PublicPositionOption(ApiModel):
|
||||
@@ -516,6 +553,15 @@ class PublicArtistOption(ApiModel):
|
||||
performance_date: dt.date
|
||||
|
||||
|
||||
class PublicPaymentOption(ApiModel):
|
||||
"""Type de paiement proposé au bénévole sur le formulaire public — un
|
||||
montant n'est pas un secret, contrairement aux identifiants SumUp."""
|
||||
|
||||
id: uuid.UUID
|
||||
label: str
|
||||
amount: float
|
||||
|
||||
|
||||
class PublicFormConfig(ApiModel):
|
||||
"""Tout ce qu'il faut pour afficher le formulaire public — jamais plus :
|
||||
ni les bénévoles déjà inscrits, ni les créneaux/affectations de
|
||||
@@ -527,8 +573,26 @@ class PublicFormConfig(ApiModel):
|
||||
artists: list[PublicArtistOption]
|
||||
has_logo: bool
|
||||
description: str | None
|
||||
yapla_url: str | None
|
||||
# `True` si l'édition a configuré SumUp (identifiants marchand + au
|
||||
# moins un type de paiement) — utilisé par le front-end pour afficher le
|
||||
# choix de paiement près de `WILLING_TO_PAY_FEE`, sans jamais exposer les
|
||||
# identifiants eux-mêmes.
|
||||
sumup_enabled: bool
|
||||
payment_options: list[PublicPaymentOption]
|
||||
|
||||
|
||||
class FormSubmissionResult(ApiModel):
|
||||
volunteer_id: uuid.UUID
|
||||
# Lien de paiement hébergé SumUp, si un checkout a pu être créé (voir
|
||||
# `app/api/forms.py:submit_public_form`) — `None` si le bénévole n'a
|
||||
# choisi aucun type de paiement, si SumUp n'est pas configuré, ou en cas
|
||||
# d'échec de l'appel API (best effort, l'inscription reste valide).
|
||||
sumup_checkout_url: str | None = None
|
||||
|
||||
|
||||
class SumUpWebhookPayload(ApiModel):
|
||||
"""Corps envoyé par SumUp à `return_url` — voir la doc SumUp sur les
|
||||
webhooks. Le seul événement existant à ce jour."""
|
||||
|
||||
event_type: str
|
||||
id: str
|
||||
|
||||
@@ -99,7 +99,9 @@ def _companion_ids(session: Session, volunteer_id: uuid.UUID) -> list[uuid.UUID]
|
||||
|
||||
def _read(session: Session, volunteer: Volunteer) -> VolunteerRead:
|
||||
return VolunteerRead(
|
||||
**volunteer.model_dump(exclude={"photo", "photo_content_type"}),
|
||||
**volunteer.model_dump(
|
||||
exclude={"photo", "photo_content_type", "fee_checkout_id"}
|
||||
),
|
||||
artist_ids=_artist_ids(session, volunteer.id),
|
||||
wished_position_ids=_wished_position_ids(session, volunteer.id),
|
||||
wished_companion_ids=_companion_ids(session, volunteer.id),
|
||||
|
||||
@@ -82,7 +82,9 @@
|
||||
"form.logo_type_not_supported": "Le logo doit être au format PNG ou JPEG.",
|
||||
"form.logo_empty": "Le logo est vide.",
|
||||
"form.logo_too_large": "Le logo est trop volumineux.",
|
||||
"form.invalid_yapla_url": "Le lien Yapla doit commencer par http:// ou https://.",
|
||||
"form.invalid_sumup_amount": "Le montant doit être supérieur à 0.",
|
||||
"form.unknown_payment_option": "Ce type de paiement n'existe pas pour cette édition.",
|
||||
"form.duplicate_payment_option_label": "Un type de paiement porte déjà ce libellé.",
|
||||
"form.too_many_position_wishes": "Deux vœux de poste au maximum.",
|
||||
"form.too_many_artists_same_day": "Un seul artiste souhaité par soir de festival.",
|
||||
"position_sheets.no_positions": "Aucun poste ne correspond à cette sélection.",
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Paiement de la cotisation via l'API SumUp (remplace le lien Yapla)
|
||||
|
||||
Revision ID: 0016_sumup_payment
|
||||
Revises: 0015_form_yapla_url
|
||||
Create Date: 2026-09-29
|
||||
|
||||
`form_template.yapla_url` (simple lien externe statique) est remplacé par un
|
||||
vrai suivi via l'API SumUp : identifiants marchand (`sumup_merchant_code`,
|
||||
`sumup_api_key`) et montant (`sumup_fee_amount`) configurés par édition, plus
|
||||
le statut de paiement par bénévole (`volunteer.fee_checkout_id`,
|
||||
`fee_payment_status`, `fee_paid_at`), tenu à jour par le webhook SumUp — voir
|
||||
`app/sumup/` et `app/api/forms.py`.
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0016_sumup_payment"
|
||||
down_revision: str | None = "0015_form_yapla_url"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.drop_column("form_template", "yapla_url")
|
||||
op.add_column(
|
||||
"form_template", sa.Column("sumup_merchant_code", sa.String(), nullable=True)
|
||||
)
|
||||
op.add_column(
|
||||
"form_template", sa.Column("sumup_api_key", sa.String(), nullable=True)
|
||||
)
|
||||
op.add_column(
|
||||
"form_template", sa.Column("sumup_fee_amount", sa.Float(), nullable=True)
|
||||
)
|
||||
|
||||
op.add_column(
|
||||
"volunteer", sa.Column("fee_checkout_id", sa.String(), nullable=True)
|
||||
)
|
||||
op.create_index(
|
||||
"ix_volunteer_fee_checkout_id", "volunteer", ["fee_checkout_id"]
|
||||
)
|
||||
op.add_column(
|
||||
"volunteer",
|
||||
sa.Column(
|
||||
"fee_payment_status",
|
||||
sa.Enum(
|
||||
"PENDING", "PAID", "FAILED", "EXPIRED",
|
||||
name="feepaymentstatus", native_enum=False,
|
||||
),
|
||||
nullable=True,
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"volunteer", sa.Column("fee_paid_at", sa.DateTime(), nullable=True)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("volunteer", "fee_paid_at")
|
||||
op.drop_column("volunteer", "fee_payment_status")
|
||||
op.drop_index("ix_volunteer_fee_checkout_id", table_name="volunteer")
|
||||
op.drop_column("volunteer", "fee_checkout_id")
|
||||
|
||||
op.drop_column("form_template", "sumup_fee_amount")
|
||||
op.drop_column("form_template", "sumup_api_key")
|
||||
op.drop_column("form_template", "sumup_merchant_code")
|
||||
op.add_column("form_template", sa.Column("yapla_url", sa.String(), nullable=True))
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Plusieurs types de paiement de la cotisation (SumUp)
|
||||
|
||||
Revision ID: 0017_form_payment_options
|
||||
Revises: 0016_sumup_payment
|
||||
Create Date: 2026-09-29
|
||||
|
||||
`form_template.sumup_fee_amount` (un montant unique) est remplacé par une
|
||||
liste libre de types de paiement (`form_payment_option` : libellé + montant,
|
||||
configurés par la coordination, comme les créneaux types `DayPart`). Le
|
||||
bénévole en choisit au plus un à l'inscription ; le libellé et le montant
|
||||
choisis sont recopiés sur `volunteer` (`fee_payment_label`/`fee_amount`) au
|
||||
moment de la création du checkout — voir `app/api/forms.py`.
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0017_form_payment_options"
|
||||
down_revision: str | None = "0016_sumup_payment"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"form_payment_option",
|
||||
sa.Column("id", sa.Uuid(), nullable=False),
|
||||
sa.Column("edition_id", sa.Uuid(), nullable=False),
|
||||
sa.Column("label", sa.String(), nullable=False),
|
||||
sa.Column("amount", sa.Float(), nullable=False),
|
||||
sa.Column("order", sa.Integer(), server_default="0", nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(["edition_id"], ["edition.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint(
|
||||
"edition_id", "label", name="uq_form_payment_option_edition_label"
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_form_payment_option_edition_id", "form_payment_option", ["edition_id"]
|
||||
)
|
||||
|
||||
op.drop_column("form_template", "sumup_fee_amount")
|
||||
|
||||
op.add_column(
|
||||
"volunteer", sa.Column("fee_payment_label", sa.String(), nullable=True)
|
||||
)
|
||||
op.add_column("volunteer", sa.Column("fee_amount", sa.Float(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("volunteer", "fee_amount")
|
||||
op.drop_column("volunteer", "fee_payment_label")
|
||||
|
||||
op.add_column(
|
||||
"form_template", sa.Column("sumup_fee_amount", sa.Float(), nullable=True)
|
||||
)
|
||||
|
||||
op.drop_index(
|
||||
"ix_form_payment_option_edition_id", table_name="form_payment_option"
|
||||
)
|
||||
op.drop_table("form_payment_option")
|
||||
@@ -15,6 +15,7 @@ from app.models.domain import (
|
||||
DayPart,
|
||||
Edition,
|
||||
FormField,
|
||||
FormPaymentOption,
|
||||
FormTemplate,
|
||||
MagicLinkToken,
|
||||
Position,
|
||||
@@ -28,6 +29,7 @@ from app.models.enums import (
|
||||
AssignmentSource,
|
||||
BadgeAlign,
|
||||
BadgeFieldType,
|
||||
FeePaymentStatus,
|
||||
FormFieldType,
|
||||
PositionCategory,
|
||||
Role,
|
||||
@@ -45,8 +47,10 @@ __all__ = [
|
||||
"BadgeTemplate",
|
||||
"DayPart",
|
||||
"Edition",
|
||||
"FeePaymentStatus",
|
||||
"FormField",
|
||||
"FormFieldType",
|
||||
"FormPaymentOption",
|
||||
"FormTemplate",
|
||||
"MagicLinkToken",
|
||||
"Position",
|
||||
|
||||
@@ -18,6 +18,7 @@ from app.models.enums import (
|
||||
AssignmentSource,
|
||||
BadgeAlign,
|
||||
BadgeFieldType,
|
||||
FeePaymentStatus,
|
||||
FormFieldType,
|
||||
PositionCategory,
|
||||
Role,
|
||||
@@ -192,6 +193,24 @@ class Volunteer(TimestampMixin, table=True):
|
||||
default=False,
|
||||
sa_column=Column(Boolean, nullable=False, server_default="false"),
|
||||
)
|
||||
# Paiement de la cotisation via l'API SumUp (`app/sumup/`, voir
|
||||
# `app/api/forms.py:submit_public_form`/`sumup_webhook`) — créé si le
|
||||
# bénévole choisit un type de paiement (`FormPaymentOption`) et que
|
||||
# l'édition a configuré SumUp (`FormTemplate.sumup_*`). `fee_payment_label`/
|
||||
# `fee_amount` sont une copie figée du libellé/montant de l'option
|
||||
# choisie au moment du choix (jamais recalculés si l'option est éditée
|
||||
# ou supprimée ensuite). `fee_checkout_id` (identifiant SumUp du
|
||||
# checkout) sert seulement à faire le lien depuis le webhook ; jamais
|
||||
# exposé (voir `app/api/volunteers.py:_read`). `fee_payment_status` reste
|
||||
# `NULL` tant qu'aucun checkout n'a été créé.
|
||||
fee_payment_label: str | None = None
|
||||
fee_amount: float | None = None
|
||||
fee_checkout_id: str | None = Field(default=None, index=True)
|
||||
fee_payment_status: FeePaymentStatus | None = Field(
|
||||
default=None,
|
||||
sa_column=Column(SAEnum(FeePaymentStatus, native_enum=False), nullable=True),
|
||||
)
|
||||
fee_paid_at: dt.datetime | None = None
|
||||
|
||||
wished_artists: list[Artist] = Relationship(
|
||||
back_populates="fans",
|
||||
@@ -467,10 +486,12 @@ class FormTemplate(TimestampMixin, table=True):
|
||||
"""Configuration du formulaire public d'une édition. Une ligne par
|
||||
édition (créée à la demande). `is_open` coupe les nouvelles soumissions
|
||||
sans effacer la configuration (les champs activés restent enregistrés).
|
||||
`logo`/`logo_content_type`, `description` et `yapla_url` habillent la
|
||||
page publique (`/inscription`) — aucun rapport avec les champs collectés
|
||||
par `FormField` ; stockage en base comme les autres images de l'appli
|
||||
(badges, photo de bénévole), pas de volume de fichiers à gérer."""
|
||||
`logo`/`logo_content_type` et `description` habillent la page publique
|
||||
(`/inscription`) — aucun rapport avec les champs collectés par
|
||||
`FormField` ; stockage en base comme les autres images de l'appli
|
||||
(badges, photo de bénévole), pas de volume de fichiers à gérer.
|
||||
`sumup_*` configurent le paiement de la cotisation via l'API SumUp (voir
|
||||
`app/sumup/`)."""
|
||||
|
||||
__tablename__ = "form_template"
|
||||
|
||||
@@ -485,12 +506,45 @@ class FormTemplate(TimestampMixin, table=True):
|
||||
logo: bytes | None = Field(default=None, sa_column=Column(LargeBinary))
|
||||
logo_content_type: str | None = None
|
||||
description: str | None = None
|
||||
# Lien vers la page de cotisation Yapla (yapla.com, plateforme externe de
|
||||
# paiement pour associations) — encart affiché sur `/inscription` près de
|
||||
# la case « je participe à la cotisation » (`FormFieldType.
|
||||
# WILLING_TO_PAY_FEE`). Rien n'est vérifié côté appli : la case reste
|
||||
# une déclaration, le paiement se passe entièrement chez Yapla.
|
||||
yapla_url: str | None = None
|
||||
# Paiement de la cotisation via l'API SumUp (`app/sumup/`) : un checkout
|
||||
# est créé à la soumission si le bénévole choisit un des types de
|
||||
# paiement configurés (`FormPaymentOption`, voir plus bas) et que ces
|
||||
# deux identifiants sont renseignés (voir `app/api/forms.py`,
|
||||
# `sumup_enabled` sur `PublicFormConfig`). `sumup_api_key` n'est jamais
|
||||
# renvoyé par l'API (voir `FormTemplateRead.sumup_api_key_set`) — c'est
|
||||
# un secret, au même titre qu'un mot de passe SMTP.
|
||||
sumup_merchant_code: str | None = None
|
||||
sumup_api_key: str | None = None
|
||||
|
||||
|
||||
class FormPaymentOption(TimestampMixin, table=True):
|
||||
"""Un type de paiement proposé sur le formulaire public (ex. « Cotisation
|
||||
classique », « Mécénat ») : libellé + montant fixe, configurés librement
|
||||
par la coordination (autant qu'elle veut, comme les créneaux types
|
||||
`DayPart`) — voir `app/api/forms.py`. Le bénévole en choisit au plus un
|
||||
à l'inscription ; le libellé et le montant réellement proposés sont
|
||||
recopiés sur `Volunteer` au moment de la création du checkout
|
||||
(`fee_payment_label`/`fee_amount`), pour ne jamais changer
|
||||
rétroactivement si cette configuration est éditée ou supprimée plus
|
||||
tard."""
|
||||
|
||||
__tablename__ = "form_payment_option"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"edition_id", "label", name="uq_form_payment_option_edition_label"
|
||||
),
|
||||
)
|
||||
|
||||
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||
edition_id: uuid.UUID = Field(
|
||||
foreign_key="edition.id", index=True, ondelete="CASCADE"
|
||||
)
|
||||
label: str
|
||||
amount: float
|
||||
order: int = Field(
|
||||
default=0,
|
||||
sa_column=Column(Integer, nullable=False, server_default="0"),
|
||||
)
|
||||
|
||||
|
||||
class FormField(SQLModel, table=True):
|
||||
|
||||
@@ -45,6 +45,18 @@ class BadgeAlign(enum.StrEnum):
|
||||
RIGHT = "RIGHT"
|
||||
|
||||
|
||||
class FeePaymentStatus(enum.StrEnum):
|
||||
"""Statut d'un paiement de cotisation via l'API SumUp (voir `app/sumup/`
|
||||
et `app/api/forms.py`). Reprend tel quel le statut d'un checkout SumUp —
|
||||
`NULL` sur `Volunteer.fee_payment_status` tant qu'aucun checkout n'a
|
||||
jamais été créé pour ce bénévole."""
|
||||
|
||||
PENDING = "PENDING"
|
||||
PAID = "PAID"
|
||||
FAILED = "FAILED"
|
||||
EXPIRED = "EXPIRED"
|
||||
|
||||
|
||||
class FormFieldType(enum.StrEnum):
|
||||
"""Champ activable sur le formulaire public d'inscription (voir
|
||||
`app/api/forms.py`). Nom/prénom sont toujours collectés, pas au choix de
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
from app.sumup.client import SumUpCheckout, create_checkout, get_checkout
|
||||
|
||||
__all__ = ["SumUpCheckout", "create_checkout", "get_checkout"]
|
||||
@@ -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
@@ -7,8 +7,26 @@ import io
|
||||
|
||||
from app.auth.rate_limit import form_submission_limiter
|
||||
from app.models import Role
|
||||
from app.sumup import SumUpCheckout
|
||||
from tests.factories import make_artist, make_position
|
||||
|
||||
_MERCHANT = "MXXXXX"
|
||||
_API_KEY = "sup_sk_secret"
|
||||
|
||||
|
||||
def _configure_sumup(client, edition, amount=12.5, label="Cotisation classique"):
|
||||
"""Active le champ, renseigne les identifiants et crée un type de
|
||||
paiement — renvoie son id, à passer en `payment_option_id` à `_submit`."""
|
||||
client.put(_form_url(edition, "/fields"), json={"fields": ["WILLING_TO_PAY_FEE"]})
|
||||
client.patch(
|
||||
_form_url(edition),
|
||||
json={"sumup_merchant_code": _MERCHANT, "sumup_api_key": _API_KEY},
|
||||
)
|
||||
resp = client.post(
|
||||
_form_url(edition, "/payment-options"), json={"label": label, "amount": amount}
|
||||
)
|
||||
return resp.json()["id"]
|
||||
|
||||
# PNG 1×1 valide (transparent), le plus petit possible.
|
||||
_PNG = base64.b64decode(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY"
|
||||
@@ -74,26 +92,130 @@ def test_clearing_description_with_an_explicit_null(client, edition):
|
||||
assert resp.json()["description"] is None
|
||||
|
||||
|
||||
def test_no_yapla_link_by_default(client, raw_client, edition):
|
||||
assert client.get(_form_url(edition)).json()["yapla_url"] is None
|
||||
assert raw_client.get(_form_url(edition, "/public")).json()["yapla_url"] is None
|
||||
def test_sumup_not_configured_by_default(client, raw_client, edition):
|
||||
body = client.get(_form_url(edition)).json()
|
||||
assert body["sumup_merchant_code"] is None
|
||||
assert body["sumup_api_key_set"] is False
|
||||
public = raw_client.get(_form_url(edition, "/public")).json()
|
||||
assert public["sumup_enabled"] is False
|
||||
assert public["payment_options"] == []
|
||||
|
||||
|
||||
def test_setting_the_yapla_link(client, raw_client, edition):
|
||||
def test_setting_sumup_credentials_never_echoes_the_api_key(client, raw_client, edition):
|
||||
resp = client.patch(
|
||||
_form_url(edition), json={"yapla_url": "https://yapla.com/exemple/cotisation"}
|
||||
_form_url(edition),
|
||||
json={"sumup_merchant_code": "MXXXXX", "sumup_api_key": "sup_sk_secret"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["yapla_url"] == "https://yapla.com/exemple/cotisation"
|
||||
body = resp.json()
|
||||
assert body["sumup_merchant_code"] == "MXXXXX"
|
||||
assert body["sumup_api_key_set"] is True
|
||||
assert "sumup_api_key" not in body
|
||||
|
||||
body = raw_client.get(_form_url(edition, "/public")).json()
|
||||
assert body["yapla_url"] == "https://yapla.com/exemple/cotisation"
|
||||
# Identifiants seuls, sans aucun type de paiement : pas encore activable.
|
||||
client.put(_form_url(edition, "/fields"), json={"fields": ["WILLING_TO_PAY_FEE"]})
|
||||
assert raw_client.get(_form_url(edition, "/public")).json()["sumup_enabled"] is False
|
||||
|
||||
client.post(_form_url(edition, "/payment-options"), json={"label": "Cotisation", "amount": 20})
|
||||
assert raw_client.get(_form_url(edition, "/public")).json()["sumup_enabled"] is True
|
||||
|
||||
|
||||
def test_rejects_a_yapla_link_without_a_scheme(client, edition):
|
||||
resp = client.patch(_form_url(edition), json={"yapla_url": "yapla.com/exemple"})
|
||||
def test_payment_options_start_empty(client, edition):
|
||||
assert client.get(_form_url(edition, "/payment-options")).json() == []
|
||||
|
||||
|
||||
def test_creating_and_listing_payment_options(client, raw_client, edition):
|
||||
client.post(
|
||||
_form_url(edition, "/payment-options"),
|
||||
json={"label": "Mécénat", "amount": 50, "order": 1},
|
||||
)
|
||||
resp = client.post(
|
||||
_form_url(edition, "/payment-options"),
|
||||
json={"label": "Cotisation classique", "amount": 20, "order": 0},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
body = resp.json()
|
||||
assert body["label"] == "Cotisation classique"
|
||||
assert body["amount"] == 20
|
||||
|
||||
listed = client.get(_form_url(edition, "/payment-options")).json()
|
||||
assert [o["label"] for o in listed] == ["Cotisation classique", "Mécénat"]
|
||||
|
||||
client.put(_form_url(edition, "/fields"), json={"fields": ["WILLING_TO_PAY_FEE"]})
|
||||
public_options = raw_client.get(_form_url(edition, "/public")).json()["payment_options"]
|
||||
assert [o["label"] for o in public_options] == ["Cotisation classique", "Mécénat"]
|
||||
assert [o["amount"] for o in public_options] == [20, 50]
|
||||
|
||||
|
||||
def test_updating_and_deleting_a_payment_option(client, edition):
|
||||
option_id = client.post(
|
||||
_form_url(edition, "/payment-options"), json={"label": "Cotisation", "amount": 20}
|
||||
).json()["id"]
|
||||
|
||||
resp = client.patch(
|
||||
_form_url(edition, f"/payment-options/{option_id}"), json={"amount": 25}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["amount"] == 25
|
||||
|
||||
resp = client.delete(_form_url(edition, f"/payment-options/{option_id}"))
|
||||
assert resp.status_code == 204
|
||||
assert client.get(_form_url(edition, "/payment-options")).json() == []
|
||||
|
||||
|
||||
def test_payment_options_are_coordinator_only(edition, make_account, as_account):
|
||||
lead = make_account(Role.LEAD, email="lead@e.fr")
|
||||
lead_client = as_account(lead)
|
||||
resp = lead_client.post(
|
||||
_form_url(edition, "/payment-options"), json={"label": "Cotisation", "amount": 20}
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_rejects_a_non_positive_payment_option_amount(client, edition):
|
||||
resp = client.post(
|
||||
_form_url(edition, "/payment-options"), json={"label": "Cotisation", "amount": 0}
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
assert resp.json()["detail"]["code"] == "form.invalid_yapla_url"
|
||||
assert resp.json()["detail"]["code"] == "form.invalid_sumup_amount"
|
||||
|
||||
|
||||
def test_rejects_a_duplicate_payment_option_label(client, edition):
|
||||
client.post(_form_url(edition, "/payment-options"), json={"label": "Cotisation", "amount": 20})
|
||||
resp = client.post(
|
||||
_form_url(edition, "/payment-options"), json={"label": "Cotisation", "amount": 30}
|
||||
)
|
||||
assert resp.status_code == 409
|
||||
assert resp.json()["detail"]["code"] == "form.duplicate_payment_option_label"
|
||||
|
||||
|
||||
def test_deleting_a_payment_option_keeps_the_volunteers_snapshot(
|
||||
raw_client, client, edition, monkeypatch
|
||||
):
|
||||
option_id = _configure_sumup(client, edition)
|
||||
monkeypatch.setattr(
|
||||
"app.api.forms.create_checkout",
|
||||
lambda **kw: SumUpCheckout(id="co_1", status="PENDING", hosted_checkout_url="https://x"),
|
||||
)
|
||||
resp = _submit(
|
||||
raw_client, edition,
|
||||
first_name="Alex", last_name="Martin", hcaptcha_token="x",
|
||||
payment_option_id=option_id,
|
||||
)
|
||||
volunteer_id = resp.json()["volunteer_id"]
|
||||
|
||||
client.delete(_form_url(edition, f"/payment-options/{option_id}"))
|
||||
|
||||
fiche = client.get(f"/editions/{edition.id}/volunteers/{volunteer_id}").json()
|
||||
assert fiche["fee_payment_label"] == "Cotisation classique"
|
||||
assert fiche["fee_amount"] == 12.5
|
||||
|
||||
|
||||
def test_clearing_the_sumup_api_key(client, edition):
|
||||
client.patch(_form_url(edition), json={"sumup_api_key": "sup_sk_secret"})
|
||||
assert client.get(_form_url(edition)).json()["sumup_api_key_set"] is True
|
||||
resp = client.patch(_form_url(edition), json={"sumup_api_key": ""})
|
||||
assert resp.json()["sumup_api_key_set"] is False
|
||||
|
||||
|
||||
def test_logo_is_coordinator_only(edition, make_account, as_account):
|
||||
@@ -360,6 +482,115 @@ def test_submitting_with_a_rejected_captcha(raw_client, edition, monkeypatch):
|
||||
assert resp.json()["detail"]["code"] == "form.captcha_failed"
|
||||
|
||||
|
||||
def test_submitting_creates_a_sumup_checkout_when_a_payment_option_is_chosen(
|
||||
raw_client, client, edition, monkeypatch
|
||||
):
|
||||
option_id = _configure_sumup(client, edition)
|
||||
monkeypatch.setattr(
|
||||
"app.api.forms.create_checkout",
|
||||
lambda **kw: SumUpCheckout(
|
||||
id="co_123", status="PENDING", hosted_checkout_url="https://checkout.sumup.com/pay/co_123"
|
||||
),
|
||||
)
|
||||
resp = _submit(
|
||||
raw_client, edition,
|
||||
first_name="Alex", last_name="Martin", hcaptcha_token="x",
|
||||
payment_option_id=option_id,
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
body = resp.json()
|
||||
assert body["sumup_checkout_url"] == "https://checkout.sumup.com/pay/co_123"
|
||||
|
||||
fiche = client.get(f"/editions/{edition.id}/volunteers/{body['volunteer_id']}").json()
|
||||
assert fiche["fee_payment_status"] == "PENDING"
|
||||
assert fiche["fee_payment_label"] == "Cotisation classique"
|
||||
assert fiche["fee_amount"] == 12.5
|
||||
assert fiche["willing_to_pay_fee"] is True
|
||||
|
||||
|
||||
def test_submitting_without_choosing_an_option_does_not_create_a_checkout(
|
||||
raw_client, client, edition, monkeypatch
|
||||
):
|
||||
_configure_sumup(client, edition)
|
||||
called = []
|
||||
monkeypatch.setattr("app.api.forms.create_checkout", lambda **kw: called.append(kw))
|
||||
resp = _submit(
|
||||
raw_client, edition, first_name="Alex", last_name="Martin", hcaptcha_token="x"
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
assert resp.json()["sumup_checkout_url"] is None
|
||||
assert called == []
|
||||
|
||||
|
||||
def test_submitting_with_an_unknown_payment_option_is_rejected(raw_client, client, edition):
|
||||
_configure_sumup(client, edition)
|
||||
resp = _submit(
|
||||
raw_client, edition,
|
||||
first_name="Alex", last_name="Martin", hcaptcha_token="x",
|
||||
payment_option_id="00000000-0000-0000-0000-000000000000",
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
assert resp.json()["detail"]["code"] == "form.unknown_payment_option"
|
||||
|
||||
|
||||
def test_a_failed_checkout_call_does_not_block_registration(
|
||||
raw_client, client, edition, monkeypatch
|
||||
):
|
||||
option_id = _configure_sumup(client, edition)
|
||||
monkeypatch.setattr("app.api.forms.create_checkout", lambda **kw: None)
|
||||
resp = _submit(
|
||||
raw_client, edition,
|
||||
first_name="Alex", last_name="Martin", hcaptcha_token="x",
|
||||
payment_option_id=option_id,
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
body = resp.json()
|
||||
assert body["sumup_checkout_url"] is None
|
||||
fiche = client.get(f"/editions/{edition.id}/volunteers/{body['volunteer_id']}").json()
|
||||
assert fiche["fee_payment_status"] is None
|
||||
|
||||
|
||||
def test_sumup_webhook_updates_the_payment_status(raw_client, client, edition, monkeypatch):
|
||||
option_id = _configure_sumup(client, edition)
|
||||
monkeypatch.setattr(
|
||||
"app.api.forms.create_checkout",
|
||||
lambda **kw: SumUpCheckout(id="co_123", status="PENDING", hosted_checkout_url="https://x"),
|
||||
)
|
||||
resp = _submit(
|
||||
raw_client, edition,
|
||||
first_name="Alex", last_name="Martin", hcaptcha_token="x",
|
||||
payment_option_id=option_id,
|
||||
)
|
||||
volunteer_id = resp.json()["volunteer_id"]
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.api.forms.get_checkout",
|
||||
lambda **kw: SumUpCheckout(id="co_123", status="PAID", hosted_checkout_url=None),
|
||||
)
|
||||
webhook_resp = raw_client.post(
|
||||
_form_url(edition, "/public/sumup-webhook"),
|
||||
json={"event_type": "CHECKOUT_STATUS_CHANGED", "id": "co_123"},
|
||||
)
|
||||
assert webhook_resp.status_code == 200
|
||||
|
||||
fiche = client.get(f"/editions/{edition.id}/volunteers/{volunteer_id}").json()
|
||||
assert fiche["fee_payment_status"] == "PAID"
|
||||
assert fiche["fee_paid_at"] is not None
|
||||
|
||||
|
||||
def test_sumup_webhook_ignores_an_unknown_checkout(raw_client, client, edition, monkeypatch):
|
||||
_configure_sumup(client, edition)
|
||||
monkeypatch.setattr(
|
||||
"app.api.forms.get_checkout",
|
||||
lambda **kw: SumUpCheckout(id="co_unknown", status="PAID", hosted_checkout_url=None),
|
||||
)
|
||||
resp = raw_client.post(
|
||||
_form_url(edition, "/public/sumup-webhook"),
|
||||
json={"event_type": "CHECKOUT_STATUS_CHANGED", "id": "co_unknown"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
def test_submitting_is_rate_limited_per_edition(raw_client, edition):
|
||||
for _ in range(form_submission_limiter.max_attempts):
|
||||
form_submission_limiter.hit(str(edition.id))
|
||||
|
||||
@@ -31,6 +31,7 @@ EXPECTED_TABLES = {
|
||||
"badge_field",
|
||||
"form_template",
|
||||
"form_field",
|
||||
"form_payment_option",
|
||||
}
|
||||
|
||||
|
||||
|
||||
+24
-13
@@ -92,19 +92,19 @@ async function checkPublicForm() {
|
||||
const beforeCount = (await api.volunteers.list(e)).length;
|
||||
|
||||
await api.forms.setFields(e, {
|
||||
fields: ['EMAIL', 'POSITION_WISHES', 'ARTIST_WISHES', 'COMMENT'],
|
||||
fields: ['EMAIL', 'POSITION_WISHES', 'ARTIST_WISHES', 'COMMENT', 'WILLING_TO_PAY_FEE'],
|
||||
});
|
||||
const config = await api.forms.getPublicConfig(e);
|
||||
if (!config.is_open) throw new Error('formulaire public : devrait être ouvert par défaut');
|
||||
if (config.positions.length !== positions.length) {
|
||||
throw new Error('formulaire public : postes manquants dans la config publique');
|
||||
}
|
||||
if (config.has_logo !== false || config.description !== null || config.yapla_url !== null) {
|
||||
throw new Error('formulaire public : logo/description/lien Yapla devraient être vides par défaut');
|
||||
if (config.has_logo !== false || config.description !== null || config.sumup_enabled !== false) {
|
||||
throw new Error('formulaire public : logo/description/SumUp devraient être vides par défaut');
|
||||
}
|
||||
// Le recadrage carré (canvas) et l'upload du logo (FileReader) sont des
|
||||
// API navigateur — non testables sous Node (voir scripts/smoke.tsx tel
|
||||
// qu'exécuté ici) ; seul le texte d'introduction et le lien Yapla sont
|
||||
// qu'exécuté ici) ; seul le texte d'introduction et la config SumUp sont
|
||||
// vérifiables ici.
|
||||
await api.forms.setOpen(e, { description: 'Bienvenue au festival de test !' });
|
||||
const configWithDescription = await api.forms.getPublicConfig(e);
|
||||
@@ -112,16 +112,24 @@ async function checkPublicForm() {
|
||||
throw new Error("formulaire public : le texte d'introduction ne revient pas correctement");
|
||||
}
|
||||
|
||||
let invalidYaplaUrlRejectedWith = 'non';
|
||||
let invalidSumupAmountRejectedWith = 'non';
|
||||
try {
|
||||
await api.forms.setOpen(e, { yapla_url: 'yapla.com/exemple' });
|
||||
await api.forms.paymentOptions.create(e, { label: 'Test invalide', amount: 0 });
|
||||
} catch (err) {
|
||||
invalidYaplaUrlRejectedWith = err instanceof ApiError ? err.code : String(err);
|
||||
invalidSumupAmountRejectedWith = err instanceof ApiError ? err.code : String(err);
|
||||
}
|
||||
await api.forms.setOpen(e, { yapla_url: 'https://www.yapla.com/fr/exemple' });
|
||||
const configWithYapla = await api.forms.getPublicConfig(e);
|
||||
if (configWithYapla.yapla_url !== 'https://www.yapla.com/fr/exemple') {
|
||||
throw new Error('formulaire public : le lien Yapla ne revient pas correctement');
|
||||
await api.forms.setOpen(e, {
|
||||
sumup_merchant_code: 'MXXXXXXX',
|
||||
sumup_api_key: 'sup_sk_test',
|
||||
});
|
||||
const classique = await api.forms.paymentOptions.create(e, {
|
||||
label: 'Cotisation classique',
|
||||
amount: 15,
|
||||
});
|
||||
await api.forms.paymentOptions.create(e, { label: 'Mécénat', amount: 50 });
|
||||
const configWithSumup = await api.forms.getPublicConfig(e);
|
||||
if (!configWithSumup.sumup_enabled || configWithSumup.payment_options.length !== 2) {
|
||||
throw new Error('formulaire public : la configuration SumUp ne revient pas correctement');
|
||||
}
|
||||
|
||||
const sameDay = artists.filter((a) => a.performance_date === artists[0]?.performance_date);
|
||||
@@ -161,6 +169,7 @@ async function checkPublicForm() {
|
||||
comment: 'Créé par le smoke test',
|
||||
position_wish_ids: positions.slice(0, 2).map((p) => p.id),
|
||||
artist_wish_ids: artists[0] ? [artists[0].id] : [],
|
||||
payment_option_id: classique.id,
|
||||
});
|
||||
const created = await api.volunteers.get(e, result.volunteer_id);
|
||||
const afterCount = (await api.volunteers.list(e)).length;
|
||||
@@ -170,12 +179,14 @@ async function checkPublicForm() {
|
||||
{
|
||||
formOpenByDefault: config.is_open,
|
||||
descriptionRoundTrips: configWithDescription.description,
|
||||
invalidYaplaUrlRejectedWith,
|
||||
yaplaUrlRoundTrips: configWithYapla.yapla_url,
|
||||
invalidSumupAmountRejectedWith,
|
||||
sumupEnabledRoundTrips: configWithSumup.sumup_enabled,
|
||||
tooManyPositionsRejectedWith,
|
||||
tooManyArtistsRejectedWith,
|
||||
createdVolunteerName: `${created.first_name} ${created.last_name}`,
|
||||
createdVolunteerAppearsInList: afterCount === beforeCount + 1,
|
||||
sumupCheckoutUrlReturned: result.sumup_checkout_url !== null,
|
||||
createdVolunteerFeeLabel: created.fee_payment_label,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
|
||||
@@ -24,6 +24,8 @@ import type {
|
||||
DayPartUpdate,
|
||||
EditionUpdate,
|
||||
FormFieldsSet,
|
||||
FormPaymentOptionCreate,
|
||||
FormPaymentOptionUpdate,
|
||||
FormTemplateUpdate,
|
||||
ImportMapping,
|
||||
PlanningNotificationRequest,
|
||||
@@ -633,6 +635,44 @@ export function useUploadFormLogo() {
|
||||
});
|
||||
}
|
||||
|
||||
/** Types de paiement de la cotisation (COORDINATION) — voir `FormPaymentOptionRead`. */
|
||||
export function useFormPaymentOptions() {
|
||||
return useQuery({
|
||||
queryKey: ['formPaymentOptions', edition],
|
||||
queryFn: () => api.forms.paymentOptions.list(edition),
|
||||
});
|
||||
}
|
||||
|
||||
function useInvalidateFormPaymentOptions() {
|
||||
const client = useQueryClient();
|
||||
return () => client.invalidateQueries({ queryKey: ['formPaymentOptions', edition] });
|
||||
}
|
||||
|
||||
export function useCreateFormPaymentOption() {
|
||||
const invalidate = useInvalidateFormPaymentOptions();
|
||||
return useMutation({
|
||||
mutationFn: (body: FormPaymentOptionCreate) => api.forms.paymentOptions.create(edition, body),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateFormPaymentOption() {
|
||||
const invalidate = useInvalidateFormPaymentOptions();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, body }: { id: string; body: FormPaymentOptionUpdate }) =>
|
||||
api.forms.paymentOptions.update(edition, id, body),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteFormPaymentOption() {
|
||||
const invalidate = useInvalidateFormPaymentOptions();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => api.forms.paymentOptions.remove(edition, id),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
|
||||
/** URL objet (aperçu) du logo du formulaire public, ou `null` si absent.
|
||||
* Utilisable sans compte connecté (`GET .../form/logo` n'est pas protégé —
|
||||
* affiché sur `/inscription`). `refreshKey` (ex. `dataUpdatedAt` de
|
||||
|
||||
@@ -14,6 +14,8 @@ import type {
|
||||
DayPartUpdate,
|
||||
EditionUpdate,
|
||||
FormFieldsSet,
|
||||
FormPaymentOptionCreate,
|
||||
FormPaymentOptionUpdate,
|
||||
FormTemplateUpdate,
|
||||
ImportMapping,
|
||||
LoginRequest,
|
||||
@@ -296,6 +298,7 @@ export function createHttpApi(baseUrl: string): PlanningApi {
|
||||
if (body.willing_to_pay_fee !== undefined) {
|
||||
form.append('willing_to_pay_fee', String(body.willing_to_pay_fee));
|
||||
}
|
||||
if (body.payment_option_id) form.append('payment_option_id', body.payment_option_id);
|
||||
if (body.position_wish_ids) {
|
||||
form.append('position_wish_ids', JSON.stringify(body.position_wish_ids));
|
||||
}
|
||||
@@ -305,6 +308,21 @@ export function createHttpApi(baseUrl: string): PlanningApi {
|
||||
if (body.photo) form.append('photo', body.photo);
|
||||
return request(`${base(editionId)}/form/public/submit`, { method: 'POST', body: form });
|
||||
},
|
||||
paymentOptions: {
|
||||
list: (editionId) => request(`${base(editionId)}/form/payment-options`),
|
||||
create: (editionId, body: FormPaymentOptionCreate) =>
|
||||
request(`${base(editionId)}/form/payment-options`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
update: (editionId, id, body: FormPaymentOptionUpdate) =>
|
||||
request(`${base(editionId)}/form/payment-options/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
remove: (editionId, id) =>
|
||||
request(`${base(editionId)}/form/payment-options/${id}`, { method: 'DELETE' }),
|
||||
},
|
||||
},
|
||||
positionSheets: {
|
||||
generate: async (editionId, body: PositionSheetsRequest) => {
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
DayPartRead,
|
||||
EditionRead,
|
||||
FormFieldType,
|
||||
FormPaymentOptionRead,
|
||||
PositionCategory,
|
||||
PositionRead,
|
||||
ShiftRead,
|
||||
@@ -56,8 +57,10 @@ export interface MockState {
|
||||
logo: string | null; // data URL
|
||||
logoType: string | null;
|
||||
description: string | null;
|
||||
yaplaUrl: string | null;
|
||||
sumupMerchantCode: string | null;
|
||||
sumupApiKey: string | null;
|
||||
};
|
||||
formPaymentOptions: FormPaymentOptionRead[];
|
||||
/** volunteerId -> photo (formulaire public), data URL. */
|
||||
photos: Map<string, string>;
|
||||
seq: number;
|
||||
@@ -273,6 +276,10 @@ export function buildMockState(): MockState {
|
||||
companion_wish_note: null,
|
||||
self_declared_available: false,
|
||||
willing_to_pay_fee: false,
|
||||
fee_payment_label: null,
|
||||
fee_amount: null,
|
||||
fee_payment_status: null,
|
||||
fee_paid_at: null,
|
||||
created_at: NOW,
|
||||
updated_at: NOW,
|
||||
});
|
||||
@@ -447,8 +454,10 @@ export function buildMockState(): MockState {
|
||||
fields: [],
|
||||
},
|
||||
formTemplate: {
|
||||
isOpen: true, fields: [], logo: null, logoType: null, description: null, yaplaUrl: null,
|
||||
isOpen: true, fields: [], logo: null, logoType: null, description: null,
|
||||
sumupMerchantCode: null, sumupApiKey: null,
|
||||
},
|
||||
formPaymentOptions: [],
|
||||
photos: new Map(),
|
||||
seq,
|
||||
};
|
||||
|
||||
@@ -20,6 +20,9 @@ import type {
|
||||
DayPartUpdate,
|
||||
EditionUpdate,
|
||||
FormFieldsSet,
|
||||
FormPaymentOptionCreate,
|
||||
FormPaymentOptionRead,
|
||||
FormPaymentOptionUpdate,
|
||||
FormSubmissionResult,
|
||||
FormTemplateRead,
|
||||
FormTemplateUpdate,
|
||||
@@ -122,15 +125,26 @@ const fileToDataUrl = (file: File): Promise<string> =>
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
|
||||
const sumupEnabled = (): boolean =>
|
||||
Boolean(
|
||||
state.formTemplate.sumupMerchantCode &&
|
||||
state.formTemplate.sumupApiKey &&
|
||||
state.formPaymentOptions.length > 0,
|
||||
);
|
||||
|
||||
const formTemplateRead = (): FormTemplateRead => ({
|
||||
edition_id: state.edition.id,
|
||||
is_open: state.formTemplate.isOpen,
|
||||
fields: state.formTemplate.fields,
|
||||
has_logo: state.formTemplate.logo !== null,
|
||||
description: state.formTemplate.description,
|
||||
yapla_url: state.formTemplate.yaplaUrl,
|
||||
sumup_merchant_code: state.formTemplate.sumupMerchantCode,
|
||||
sumup_api_key_set: state.formTemplate.sumupApiKey !== null,
|
||||
});
|
||||
|
||||
const sortedPaymentOptions = (): FormPaymentOptionRead[] =>
|
||||
[...state.formPaymentOptions].sort((a, b) => a.order - b.order || a.label.localeCompare(b.label));
|
||||
|
||||
// PDF valide minimal (une page blanche) — remplace la vraie génération
|
||||
// (réservée au back-end réel) pour que le téléchargement fonctionne en mode
|
||||
// démonstration.
|
||||
@@ -856,6 +870,10 @@ export function createMockApi(): PlanningApi {
|
||||
companion_wish_note: null,
|
||||
self_declared_available: false,
|
||||
willing_to_pay_fee: false,
|
||||
fee_payment_label: null,
|
||||
fee_amount: null,
|
||||
fee_payment_status: null,
|
||||
fee_paid_at: null,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
@@ -1191,6 +1209,10 @@ export function createMockApi(): PlanningApi {
|
||||
companion_wish_note: null,
|
||||
self_declared_available: false,
|
||||
willing_to_pay_fee: false,
|
||||
fee_payment_label: null,
|
||||
fee_amount: null,
|
||||
fee_payment_status: null,
|
||||
fee_paid_at: null,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
@@ -1484,11 +1506,11 @@ export function createMockApi(): PlanningApi {
|
||||
setOpen: (_edition, body: FormTemplateUpdate) => {
|
||||
if (body.is_open !== undefined) state.formTemplate.isOpen = body.is_open;
|
||||
if (body.description !== undefined) state.formTemplate.description = body.description;
|
||||
if (body.yapla_url !== undefined) {
|
||||
if (body.yapla_url && !/^https?:\/\//.test(body.yapla_url)) {
|
||||
throw new ApiError(422, 'form.invalid_yapla_url');
|
||||
}
|
||||
state.formTemplate.yaplaUrl = body.yapla_url;
|
||||
if (body.sumup_merchant_code !== undefined) {
|
||||
state.formTemplate.sumupMerchantCode = body.sumup_merchant_code;
|
||||
}
|
||||
if (body.sumup_api_key !== undefined) {
|
||||
state.formTemplate.sumupApiKey = body.sumup_api_key || null;
|
||||
}
|
||||
return delay<FormTemplateRead>(formTemplateRead());
|
||||
},
|
||||
@@ -1518,9 +1540,62 @@ export function createMockApi(): PlanningApi {
|
||||
: [],
|
||||
has_logo: state.formTemplate.logo !== null,
|
||||
description: state.formTemplate.description,
|
||||
yapla_url: state.formTemplate.yaplaUrl,
|
||||
sumup_enabled: sumupEnabled(),
|
||||
payment_options: sortedPaymentOptions().map((o) => ({
|
||||
id: o.id,
|
||||
label: o.label,
|
||||
amount: o.amount,
|
||||
})),
|
||||
});
|
||||
},
|
||||
paymentOptions: {
|
||||
list: (): Promise<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) => {
|
||||
if (!state.formTemplate.isOpen) throw new ApiError(422, 'form.closed');
|
||||
if (!body.hcaptcha_token) throw new ApiError(422, 'form.captcha_failed');
|
||||
@@ -1539,6 +1614,12 @@ export function createMockApi(): PlanningApi {
|
||||
throw new ApiError(422, 'form.too_many_artists_same_day');
|
||||
}
|
||||
|
||||
let paymentOption: FormPaymentOptionRead | null = null;
|
||||
if (fields.has('WILLING_TO_PAY_FEE') && body.payment_option_id) {
|
||||
paymentOption = state.formPaymentOptions.find((o) => o.id === body.payment_option_id) ?? null;
|
||||
if (!paymentOption) throw new ApiError(422, 'form.unknown_payment_option');
|
||||
}
|
||||
|
||||
const now = touch();
|
||||
const volunteer: VolunteerRead = {
|
||||
id: `v${state.seq++}`,
|
||||
@@ -1557,8 +1638,12 @@ export function createMockApi(): PlanningApi {
|
||||
: null,
|
||||
self_declared_available: fields.has('AVAILABLE') ? Boolean(body.available) : false,
|
||||
willing_to_pay_fee: fields.has('WILLING_TO_PAY_FEE')
|
||||
? Boolean(body.willing_to_pay_fee)
|
||||
? Boolean(body.willing_to_pay_fee) || paymentOption !== null
|
||||
: false,
|
||||
fee_payment_label: null,
|
||||
fee_amount: null,
|
||||
fee_payment_status: null,
|
||||
fee_paid_at: null,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
@@ -1568,9 +1653,22 @@ export function createMockApi(): PlanningApi {
|
||||
state.photos.set(volunteer.id, await fileToDataUrl(body.photo));
|
||||
}
|
||||
|
||||
// Simule la création d'un checkout SumUp (pas de vrai appel réseau
|
||||
// en mode démonstration) — voir `app/api/forms.py:_create_fee_checkout`.
|
||||
let checkoutUrl: string | null = null;
|
||||
if (paymentOption && state.formTemplate.sumupMerchantCode && state.formTemplate.sumupApiKey) {
|
||||
volunteer.fee_payment_label = paymentOption.label;
|
||||
volunteer.fee_amount = paymentOption.amount;
|
||||
volunteer.fee_payment_status = 'PENDING';
|
||||
checkoutUrl = `https://checkout.sumup.com/pay/mock-${volunteer.id}`;
|
||||
}
|
||||
|
||||
state.volunteers.push(volunteer);
|
||||
state.availability.set(volunteer.id, []);
|
||||
return delay<FormSubmissionResult>({ volunteer_id: volunteer.id });
|
||||
return delay<FormSubmissionResult>({
|
||||
volunteer_id: volunteer.id,
|
||||
sumup_checkout_url: checkoutUrl,
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
@@ -113,10 +113,20 @@ export interface VolunteerRead {
|
||||
companion_wish_note: string | null;
|
||||
self_declared_available: boolean;
|
||||
willing_to_pay_fee: boolean;
|
||||
/** Paiement de la cotisation via SumUp (voir schema forms) —
|
||||
* `fee_payment_label`/`fee_amount` : copie figée du type de paiement
|
||||
* choisi (pas la config actuelle). `fee_payment_status` reste `null` tant
|
||||
* qu'aucun checkout n'a jamais été créé pour ce bénévole. */
|
||||
fee_payment_label: string | null;
|
||||
fee_amount: number | null;
|
||||
fee_payment_status: FeePaymentStatus | null;
|
||||
fee_paid_at: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export type FeePaymentStatus = 'PENDING' | 'PAID' | 'FAILED' | 'EXPIRED';
|
||||
|
||||
export interface VolunteerCreate {
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
@@ -573,14 +583,43 @@ export interface FormTemplateRead {
|
||||
fields: FormFieldType[];
|
||||
has_logo: boolean;
|
||||
description: string | null;
|
||||
yapla_url: string | null;
|
||||
sumup_merchant_code: string | null;
|
||||
/** `sumup_api_key` (secret) n'est jamais renvoyé — seulement s'il est défini. */
|
||||
sumup_api_key_set: boolean;
|
||||
}
|
||||
|
||||
/** `undefined`/absent = inchangé pour chaque champ. */
|
||||
/** `undefined`/absent = inchangé pour chaque champ. Chaîne vide sur
|
||||
* `sumup_api_key` efface le secret enregistré. */
|
||||
export interface FormTemplateUpdate {
|
||||
is_open?: boolean;
|
||||
description?: string | null;
|
||||
yapla_url?: string | null;
|
||||
sumup_merchant_code?: string | null;
|
||||
sumup_api_key?: string | null;
|
||||
}
|
||||
|
||||
/** Un type de paiement proposé sur le formulaire public (ex. « Cotisation
|
||||
* classique », « Mécénat ») — libellé + montant fixe, configurés librement
|
||||
* par la coordination (comme les créneaux types `DayPart`). */
|
||||
export interface FormPaymentOptionRead {
|
||||
id: string;
|
||||
edition_id: string;
|
||||
label: string;
|
||||
amount: number;
|
||||
order: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface FormPaymentOptionCreate {
|
||||
label: string;
|
||||
amount: number;
|
||||
order?: number;
|
||||
}
|
||||
|
||||
export interface FormPaymentOptionUpdate {
|
||||
label?: string;
|
||||
amount?: number;
|
||||
order?: number;
|
||||
}
|
||||
|
||||
export interface PublicPositionOption {
|
||||
@@ -594,6 +633,14 @@ export interface PublicArtistOption {
|
||||
performance_date: string;
|
||||
}
|
||||
|
||||
/** Type de paiement affiché sur le formulaire public — un montant n'est pas
|
||||
* un secret, contrairement aux identifiants SumUp. */
|
||||
export interface PublicPaymentOption {
|
||||
id: string;
|
||||
label: string;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
/** Tout ce qu'il faut pour afficher le formulaire public — jamais plus. */
|
||||
export interface PublicFormConfig {
|
||||
is_open: boolean;
|
||||
@@ -602,11 +649,16 @@ export interface PublicFormConfig {
|
||||
artists: PublicArtistOption[];
|
||||
has_logo: boolean;
|
||||
description: string | null;
|
||||
yapla_url: string | null;
|
||||
/** `true` si l'édition a configuré SumUp (identifiants + au moins un type
|
||||
* de paiement). */
|
||||
sumup_enabled: boolean;
|
||||
payment_options: PublicPaymentOption[];
|
||||
}
|
||||
|
||||
export interface FormSubmissionResult {
|
||||
volunteer_id: string;
|
||||
/** Lien de paiement hébergé SumUp, si un checkout a pu être créé. */
|
||||
sumup_checkout_url: string | null;
|
||||
}
|
||||
|
||||
/** Champs du formulaire public — envoyés en `multipart/form-data` (photo
|
||||
@@ -622,6 +674,9 @@ export interface PublicFormSubmission {
|
||||
companion_wish_note?: string;
|
||||
available?: boolean;
|
||||
willing_to_pay_fee?: boolean;
|
||||
/** Type de paiement choisi (voir `PublicPaymentOption`) — `undefined` =
|
||||
* aucun (le bénévole ne participe pas à la cotisation). */
|
||||
payment_option_id?: string;
|
||||
position_wish_ids?: string[];
|
||||
artist_wish_ids?: string[];
|
||||
photo?: File | null;
|
||||
|
||||
@@ -24,6 +24,9 @@ import type {
|
||||
EditionRead,
|
||||
EditionUpdate,
|
||||
FormFieldsSet,
|
||||
FormPaymentOptionCreate,
|
||||
FormPaymentOptionRead,
|
||||
FormPaymentOptionUpdate,
|
||||
FormSubmissionResult,
|
||||
FormTemplateRead,
|
||||
FormTemplateUpdate,
|
||||
@@ -173,6 +176,17 @@ export interface PlanningApi {
|
||||
getPublicConfig(editionId: string): Promise<PublicFormConfig>;
|
||||
submitPublic(editionId: string, body: PublicFormSubmission): Promise<FormSubmissionResult>;
|
||||
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: {
|
||||
/** Feuilles de poste PDF (une page par poste et par jour). */
|
||||
|
||||
@@ -381,6 +381,10 @@
|
||||
"form.field.available": "Case « je suis disponible »",
|
||||
"form.field.available.help": "Purement informatif — n'a aucun lien avec les disponibilités détaillées de la fiche bénévole.",
|
||||
"form.field.willingToPayFee": "Case « je veux bien payer une cotisation »",
|
||||
"config.vol.feeStatus.pending": "Cotisation : paiement en attente",
|
||||
"config.vol.feeStatus.paid": "Cotisation payée ✓",
|
||||
"config.vol.feeStatus.failed": "Cotisation : paiement échoué",
|
||||
"config.vol.feeStatus.expired": "Cotisation : lien de paiement expiré",
|
||||
"form.field.photo": "Une magnifique photo de toi :",
|
||||
"form.field.comment": "Commentaire",
|
||||
"form.link.title": "Lien du formulaire",
|
||||
@@ -401,18 +405,28 @@
|
||||
"form.customize.logo.uploading": "Envoi…",
|
||||
"form.customize.description": "Texte d'introduction",
|
||||
"form.customize.description.placeholder": "Un mot de bienvenue affiché en haut du formulaire (facultatif)…",
|
||||
"form.customize.yaplaUrl": "Lien de cotisation Yapla",
|
||||
"form.customize.yaplaUrl.help": "Facultatif : lien vers votre page de cotisation sur Yapla. Un encart « Payer ma cotisation » apparaît alors sur le formulaire public, près de la case correspondante.",
|
||||
"form.customize.yaplaUrl.placeholder": "https://www.yapla.com/fr/...",
|
||||
"form.customize.sumup.title": "Paiement de la cotisation (SumUp)",
|
||||
"form.customize.sumup.help": "Facultatif : identifiants de votre compte marchand SumUp, et au moins un type de paiement ci-dessous (libellé + montant, ex. « Cotisation classique », « Mécénat »). Une fois les deux renseignés, le bénévole choisit un type à l'inscription et se voit proposer un lien de paiement juste après.",
|
||||
"form.customize.sumup.merchantCode": "Code marchand",
|
||||
"form.customize.sumup.merchantCode.placeholder": "MXXXXXXX",
|
||||
"form.customize.sumup.apiKey": "Clé API",
|
||||
"form.customize.sumup.apiKey.set": "(déjà enregistrée)",
|
||||
"form.customize.sumup.apiKey.placeholder": "sup_sk_...",
|
||||
"form.customize.paymentOptions.title": "Types de paiement",
|
||||
"form.customize.paymentOptions.label": "Libellé (ex. Cotisation classique, Mécénat…)",
|
||||
"form.customize.paymentOptions.add": "Ajouter",
|
||||
|
||||
"publicForm.title": "Inscription bénévole",
|
||||
"publicForm.positionWishes.count": "{count} / {max}",
|
||||
"publicForm.artistWishes.none": "— Aucun —",
|
||||
"publicForm.field.available": "Je suis bénévole les deux jours (vendredi 30 et samedi 31 oct.)",
|
||||
"publicForm.field.willingToPayFee": "Je participe à la cotisation bénévole de 20€",
|
||||
"publicForm.field.willingToPayFee": "Je participe à la cotisation bénévole",
|
||||
"publicForm.field.paymentOption": "Cotisation",
|
||||
"publicForm.paymentOption.none": "Je ne participe pas",
|
||||
"publicForm.photo.squareHint": "Recadrée automatiquement en carré.",
|
||||
"publicForm.yapla.hint": "Le paiement se fait en ligne, via notre page Yapla.",
|
||||
"publicForm.yapla.cta": "Payer ma cotisation ↗",
|
||||
"publicForm.sumup.afterSubmitHint": "Un lien de paiement en ligne (SumUp) te sera proposé juste après l'envoi de ton inscription.",
|
||||
"publicForm.sumup.hint": "Le paiement se fait en ligne, via SumUp.",
|
||||
"publicForm.sumup.cta": "Payer ma cotisation ↗",
|
||||
"publicForm.captcha.disabled": "(Vérification anti-robot non configurée sur cette instance.)",
|
||||
"publicForm.submitting": "Envoi…",
|
||||
"publicForm.submit": "Envoyer mon inscription",
|
||||
@@ -428,7 +442,9 @@
|
||||
"form.logo_type_not_supported": "Le logo doit être au format PNG ou JPEG.",
|
||||
"form.logo_empty": "Le logo est vide.",
|
||||
"form.logo_too_large": "Le logo est trop volumineux.",
|
||||
"form.invalid_yapla_url": "Le lien Yapla doit commencer par http:// ou https://.",
|
||||
"form.invalid_sumup_amount": "Le montant doit être supérieur à 0.",
|
||||
"form.unknown_payment_option": "Ce type de paiement n'existe pas pour cette édition.",
|
||||
"form.duplicate_payment_option_label": "Un type de paiement porte déjà ce libellé.",
|
||||
"form.too_many_position_wishes": "Deux vœux de poste au maximum.",
|
||||
"form.too_many_artists_same_day": "Un seul artiste souhaité par soir de festival.",
|
||||
"position_sheets.no_positions": "Aucun poste ne correspond à cette sélection.",
|
||||
|
||||
@@ -2,9 +2,13 @@ import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { ApiError } from '@/api';
|
||||
import {
|
||||
useCreateFormPaymentOption,
|
||||
useDeleteFormPaymentOption,
|
||||
useFormLogoUrl,
|
||||
useFormPaymentOptions,
|
||||
useFormTemplate,
|
||||
useSetFormFields,
|
||||
useUpdateFormPaymentOption,
|
||||
useUpdateFormTemplate,
|
||||
useUploadFormLogo,
|
||||
} from '@/api/hooks';
|
||||
@@ -60,11 +64,11 @@ const FIELD_HELP_KEY: Record<FormFieldType, string | null> = {
|
||||
export function FormBuilderPage() {
|
||||
const template = useFormTemplate();
|
||||
const setFields = useSetFormFields();
|
||||
// Trois instances indépendantes du même mutateur (même endpoint PATCH) :
|
||||
// Quatre instances indépendantes du même mutateur (même endpoint PATCH) :
|
||||
// le statut (pending/success) de l'une ne doit pas déteindre sur l'autre.
|
||||
const toggleOpen = useUpdateFormTemplate();
|
||||
const saveDescription = useUpdateFormTemplate();
|
||||
const saveYaplaUrl = useUpdateFormTemplate();
|
||||
const saveSumUp = useUpdateFormTemplate();
|
||||
const uploadLogo = useUploadFormLogo();
|
||||
const logoUrl = useFormLogoUrl(template.data?.has_logo ?? false, template.dataUpdatedAt);
|
||||
const logoInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -72,7 +76,8 @@ export function FormBuilderPage() {
|
||||
const [enabled, setEnabled] = useState<Set<FormFieldType> | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [description, setDescription] = useState('');
|
||||
const [yaplaUrl, setYaplaUrl] = useState('');
|
||||
const [sumupMerchantCode, setSumupMerchantCode] = useState('');
|
||||
const [sumupApiKey, setSumupApiKey] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (template.data) setEnabled(new Set(template.data.fields));
|
||||
@@ -81,7 +86,7 @@ export function FormBuilderPage() {
|
||||
if (template.data) setDescription(template.data.description ?? '');
|
||||
}, [template.data]);
|
||||
useEffect(() => {
|
||||
if (template.data) setYaplaUrl(template.data.yapla_url ?? '');
|
||||
if (template.data) setSumupMerchantCode(template.data.sumup_merchant_code ?? '');
|
||||
}, [template.data]);
|
||||
|
||||
if (template.isLoading || !enabled) return <Loading />;
|
||||
@@ -241,37 +246,167 @@ export function FormBuilderPage() {
|
||||
</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]">
|
||||
<FieldLabel>{t('form.customize.yaplaUrl')}</FieldLabel>
|
||||
<HelpTip text={t('form.customize.yaplaUrl.help')} />
|
||||
<FieldLabel>{t('form.customize.sumup.title')}</FieldLabel>
|
||||
<HelpTip text={t('form.customize.sumup.help')} />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={yaplaUrl}
|
||||
onChange={(e) => setYaplaUrl(e.target.value)}
|
||||
placeholder={t('form.customize.yaplaUrl.placeholder')}
|
||||
className="w-full border-2 border-ink bg-cream px-[9px] py-[7px] font-mono text-[11px]"
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<label className="block">
|
||||
<FieldLabel>{t('form.customize.sumup.merchantCode')}</FieldLabel>
|
||||
<input
|
||||
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">
|
||||
<Btn
|
||||
variant="ghost"
|
||||
onClick={() => saveYaplaUrl.mutate({ yapla_url: yaplaUrl.trim() || null })}
|
||||
disabled={saveYaplaUrl.isPending}
|
||||
onClick={() =>
|
||||
saveSumUp.mutate(
|
||||
{
|
||||
sumup_merchant_code: sumupMerchantCode.trim() || null,
|
||||
// Laissé vide = inchangé (le secret déjà enregistré n'est
|
||||
// jamais renvoyé par l'API, donc jamais pré-rempli ici) —
|
||||
// voir `sumup_api_key_set` pour savoir s'il y en a un.
|
||||
...(sumupApiKey.trim() ? { sumup_api_key: sumupApiKey.trim() } : {}),
|
||||
},
|
||||
{ onSuccess: () => setSumupApiKey('') },
|
||||
)
|
||||
}
|
||||
disabled={saveSumUp.isPending}
|
||||
>
|
||||
{saveYaplaUrl.isPending ? t('form.saving') : t('form.save')}
|
||||
{saveSumUp.isPending ? t('form.saving') : t('form.save')}
|
||||
</Btn>
|
||||
{saveYaplaUrl.isSuccess ? (
|
||||
{saveSumUp.isSuccess ? (
|
||||
<span className="font-mono text-[9.5px] font-bold text-green">{t('form.saved')}</span>
|
||||
) : null}
|
||||
{saveYaplaUrl.isError ? (
|
||||
{saveSumUp.isError ? (
|
||||
<span className="font-mono text-[9.5px] font-bold text-orange-dark">
|
||||
{errorMessage(saveYaplaUrl.error)}
|
||||
{errorMessage(saveSumUp.error)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="border-t-2 border-ink pt-3">
|
||||
<FieldLabel>{t('form.customize.paymentOptions.title')}</FieldLabel>
|
||||
<PaymentOptionsForm />
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -138,6 +138,7 @@ export function PublicSignupPage() {
|
||||
const [companionNote, setCompanionNote] = useState('');
|
||||
const [available, setAvailable] = useState(false);
|
||||
const [willingToPayFee, setWillingToPayFee] = useState(false);
|
||||
const [paymentOptionId, setPaymentOptionId] = useState('');
|
||||
const [positionIds, setPositionIds] = useState<string[]>([]);
|
||||
const [artistByDay, setArtistByDay] = useState<Record<string, string>>({});
|
||||
const [photo, setPhoto] = useState<File | null>(null);
|
||||
@@ -145,6 +146,7 @@ export function PublicSignupPage() {
|
||||
const [captchaToken, setCaptchaToken] = useState('');
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
const [volunteerId, setVolunteerId] = useState<string | null>(null);
|
||||
const [sumupCheckoutUrl, setSumupCheckoutUrl] = useState<string | null>(null);
|
||||
|
||||
const artistsByDay = useMemo(
|
||||
() => groupArtistsByDay(config.data?.artists ?? []),
|
||||
@@ -180,7 +182,24 @@ export function PublicSignupPage() {
|
||||
<FormLogo url={logoUrl} />
|
||||
<Panel>
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
@@ -238,11 +257,16 @@ export function PublicSignupPage() {
|
||||
companion_wish_note: fields.has('COMPANION_WISH') ? companionNote : undefined,
|
||||
available: fields.has('AVAILABLE') ? available : undefined,
|
||||
willing_to_pay_fee: fields.has('WILLING_TO_PAY_FEE') ? willingToPayFee : undefined,
|
||||
payment_option_id:
|
||||
fields.has('WILLING_TO_PAY_FEE') && config.data.sumup_enabled
|
||||
? paymentOptionId || undefined
|
||||
: undefined,
|
||||
position_wish_ids: fields.has('POSITION_WISHES') ? positionIds : undefined,
|
||||
artist_wish_ids: fields.has('ARTIST_WISHES') ? Object.values(artistByDay) : undefined,
|
||||
photo: fields.has('PHOTO') ? photo : undefined,
|
||||
});
|
||||
setVolunteerId(result.volunteer_id);
|
||||
setSumupCheckoutUrl(result.sumup_checkout_url);
|
||||
} catch (err) {
|
||||
setSubmitError(errorMessage(err));
|
||||
}
|
||||
@@ -374,7 +398,43 @@ export function PublicSignupPage() {
|
||||
</label>
|
||||
) : 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]">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -386,22 +446,6 @@ export function PublicSignupPage() {
|
||||
</label>
|
||||
) : 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') ? (
|
||||
<label className="block">
|
||||
<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`) —
|
||||
* 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;
|
||||
if (!hasAnything) return null;
|
||||
|
||||
const feeStatusKey = volunteer.fee_payment_status
|
||||
? FEE_STATUS_LABEL_KEY[volunteer.fee_payment_status]
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 border-2 border-ink bg-sand p-[9px]">
|
||||
<FieldLabel>
|
||||
@@ -228,6 +239,12 @@ function PublicFormAnswers({
|
||||
/>
|
||||
<span className="font-mono text-[11px]">{t('form.field.willingToPayFee')}</span>
|
||||
</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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user