Compare commits

..
4 Commits
Author SHA1 Message Date
AdamThomasandClaude Opus 5.5 c49ce520e3 Type de paiement à montant libre (pensé pour le mécénat)
ci-cd / build-backend (push) Successful in 33s
ci-cd / build-frontend (push) Successful in 18s
ci-cd / deploy (push) Successful in 12s
FormPaymentOption.free_amount (migration 0021) : case « Montant libre »
sur l'écran Inscriptions ; le montant configuré devient le minimum et le
bénévole saisit le sien sur le formulaire public (payment_amount, virgule
ou point, 2 décimales max, plafond 10 000 € contre une faute de frappe —
422 form.invalid_free_amount sinon). Validé côté client avant l'envoi,
le serveur restant seul juge. Le montant choisi est celui du checkout
SumUp, recopié sur la fiche, l'email de confirmation et le cerfa.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 13:49:27 +02:00
AdamThomasandClaude Opus 5.5 ccd769d04e Numéro d'ordre unique sur chaque reçu fiscal mécénat
Volunteer.mecenat_receipt_number (migration 0020, unique) : année du
paiement + rang sur 5 chiffres (202600001…), une seule numérotation pour
toutes les éditions, repartant à 1 chaque année. Attribué à la
confirmation du paiement, ou au premier export pour les bénévoles payés
avant (dans l'ordre des paiements), puis jamais réattribué — un reçu
regénéré garde son numéro. Attribution sérialisée par un verrou
pg_advisory_xact_lock (webhook et sondage peuvent confirmer deux
paiements à la fois). Imprimé centré dans la case « Numéro d'ordre du
reçu » en page 1 du cerfa.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 13:48:05 +02:00
AdamThomasandClaude Opus 5.5 5a0209ce43 Cerfa mécénat rempli envoyé par email à la confirmation du paiement
Quand un paiement « mécénat » est confirmé, l'email de confirmation porte
en pièce jointe le reçu fiscal (cerfa 11580*05) rempli du bénévole, avec
une phrase l'annonçant dans le corps. Généré en tâche de fond : un échec
de génération envoie l'email sans pièce jointe plutôt que rien.

- build_message/send acceptent des pièces jointes
- app/mecenat/receipts.py : passage Volunteer → MecenatReceipt, partagé
  avec l'export coordination
- modèle cerfa remplacé par une version pré-remplie avec l'association
  (organisme bénéficiaire, cases 200/978, signature) ; position de la
  date du versement ajustée

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 13:47:45 +02:00
AdamThomasandClaude Opus 5.5 5675e1d220 Formulaire public : astérisque rouge sur les champs obligatoires
Composant RequiredMark et prop `required` sur FieldLabel ; marqués
uniquement les champs réellement exigés à la soumission (nom, prénom,
email/téléphone/disponibilité/cotisation s'ils sont activés, étape
d'adresse mécénat), avec une légende « * champ obligatoire » en tête du
formulaire. Champ photo déplacé avant la disponibilité, libellé précisé
(« pour ton badge »).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 13:47:24 +02:00
23 changed files with 735 additions and 91 deletions
+33 -4
View File
@@ -216,9 +216,21 @@ les tables sauf les tables d'association.
`app/mecenat/number_words.py:amount_in_words` — conversion française pure,
testée séparément des coordonnées PDF vu le nombre d'exceptions
orthographiques : accord de « quatre-vingts »/« cents », « et onze »
irrégulier à 71) et la date du versement sont complétés ; l'encadré
« Organisme bénéficiaire » (page 1) reste vierge, à la main,
volontairement — pas de données de l'association en base à ce jour.
irrégulier à 71) et la date du versement sont complétés. L'encadré
« Organisme bénéficiaire » (page 1), les cases articles 200/978 et la
signature (page 2) sont **pré-remplis dans le modèle PDF lui-même** par
l'association (pas en base) : changer d'association = remplacer le
fichier, en gardant la même mise en page (les coordonnées de l'overlay
n'ont été mesurées que sur ce document). **Numéro d'ordre** du reçu
(case en haut de la page 1) : `Volunteer.mecenat_receipt_number`, unique,
`AAAA` (année du paiement) + rang sur 5 chiffres (`202600001`), une seule
numérotation pour toutes les éditions, repartant à 1 chaque année —
attribué à la confirmation du paiement (`_promote_pending`) ou, pour un
bénévole qui n'en a pas encore, au premier export (dans l'ordre des
paiements), puis **jamais réattribué** (un reçu regénéré garde son
numéro). `app/mecenat/receipts.py:assign_receipt_number`, sérialisé par
un verrou `pg_advisory_xact_lock` (webhook et sondage peuvent confirmer
deux paiements à la fois).
`pypdf` (nouvelle dépendance) fusionne l'overlay ReportLab sur la page du
modèle. Bouton « Reçus mécénat (PDF) » sur l'écran de configuration des
bénévoles, même mécanique de téléchargement que l'export Excel ;
@@ -243,7 +255,13 @@ les tables sauf les tables d'association.
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`, `requires_mecenat_info` (voir plus bas). Liste libre par
fixe), `order`, `requires_mecenat_info` (voir plus bas), `free_amount`
(**montant libre** : le bénévole saisit lui-même son montant sur le
formulaire public — `payment_amount` à la soumission, virgule ou point,
2 décimales max — et `amount` en devient le **minimum** ; plafond
10 000 € contre une faute de frappe, 422 `form.invalid_free_amount`
sinon, `app/api/forms.py:_free_amount`. Indépendant de la case mécénat,
mais pensé pour elle : un don libre avec reçu fiscal). 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`
@@ -484,6 +502,13 @@ ni corps**). Config : `SMTP_HOST/PORT/USER/PASSWORD/STARTTLS/SSL/FROM`,
mécanisme pour que SumUp envoie elle-même un reçu au payeur (son
« envoi automatique de reçu » vise le lecteur de carte/point de vente, pas
ce mode d'intégration), donc l'appli ne compte pas dessus.
Paiement « mécénat » (adresse postale saisie) : ce même email porte en
pièce jointe le cerfa 11580*05 rempli du bénévole
(`app/mecenat/receipts.py:receipt_for` + `generate_mecenat_receipts_pdf`,
le même rendu que l'export coordination — organisme bénéficiaire et
signature pré-remplis dans le modèle). Généré dans la tâche de fond
(`_send_paid_confirmation`) ; un échec de génération envoie l'email sans
pièce jointe plutôt que rien.
---
@@ -821,6 +846,10 @@ Aucune dépendance locale requise hors Docker.
| 29 | Jeu de démonstration : 8 bénévoles avec une cotisation classique payée, 4 avec une cotisation mécénat payée (adresse complète) — `app/seed.py:_add_demo_payments` — pour que l'export Excel et les reçus fiscaux mécénat (étape 28) aient de quoi montrer quelque chose d'emblée | à valider |
| 30 | Infobulles (`HelpTip` et détail de cellule de la grille des besoins) repositionnées en `position: fixed` via JS (`lib/useTooltipPosition.ts`), bornées à l'intérieur de l'écran et basculant de côté si la place manque — corrige un centrage `absolute` qui les faisait déborder de l'écran près d'un bord, et une isolation `z-index` qui rendait celle de la grille invisible (coincée sous les en-têtes figés) | à valider |
| 31 | Revue de sécurité : logo des emails en pièce jointe inline au lieu d'une URL (fuyait le `access_token` du formulaire public à tout destinataire — régression de l'étape 27, aucun test ne couvrait le rendu HTML du logo), sujet retiré du log de remise des emails (pouvait porter le nom d'un bénévole via un modèle personnalisé), image du formulaire public revalidée par décodage réel (`PIL`, pas seulement le `Content-Type` déclaré) avant stockage — évite qu'une fausse image fasse échouer la génération des badges/feuilles de poste, export Excel des bénévoles protégé contre l'injection de formule (CWE-1236, `app/exporting/xlsx.py:_safe_text`), sondage du statut de paiement en attente limité en fréquence par jeton (`pending_status_limiter`, évite un abus de l'API SumUp), `pyjwt` mis à jour (CVE listées non exploitables ici — HS256 à secret statique uniquement, jamais de JWKS — mais autant ne pas rester sur une version vulnérable) | à valider |
| 32 | Formulaire public : astérisque rouge sur les champs obligatoires (`RequiredMark`, `FieldLabel required`) et légende « * champ obligatoire » | à valider |
| 33 | Cerfa mécénat rempli envoyé en pièce jointe de l'email de confirmation une fois le paiement confirmé (`build_message(attachments=…)`, `app/mecenat/receipts.py`) ; modèle cerfa pré-rempli avec l'association et sa signature | à valider |
| 34 | Numéro d'ordre unique sur chaque reçu fiscal mécénat (`Volunteer.mecenat_receipt_number`, `202600001`…, migration `0020`), attribué au paiement ou au premier export puis figé | à valider |
| 35 | Type de paiement à montant libre (`FormPaymentOption.free_amount`, migration `0021`) : case « Montant libre » sur l'écran Inscriptions, montant configuré = minimum, saisie du montant sur le formulaire public (validée côté client et serveur, 10 000 € max) — pensé pour le mécénat | à valider |
**Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur
de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans +
+75 -2
View File
@@ -30,6 +30,7 @@ import json
import logging
import secrets
import uuid
from decimal import Decimal, InvalidOperation
from fastapi import (
APIRouter,
@@ -81,6 +82,13 @@ from app.email import (
send,
)
from app.forms import verify_hcaptcha
from app.mecenat import (
RECEIPT_FILENAME,
MecenatReceipt,
assign_receipt_number,
generate_mecenat_receipts_pdf,
receipt_for,
)
from app.models import (
Account,
Artist,
@@ -106,6 +114,9 @@ _ALLOWED_PHOTO_TYPES = {"image/png", "image/jpeg"}
# Vœux de poste : au plus deux, pour éviter que tout le monde coche tous les
# postes « au cas où » — force une vraie priorisation dès le formulaire.
_MAX_POSITION_WISHES = 2
# Plafond d'un montant libre (`FormPaymentOption.free_amount`) : garde-fou
# contre une faute de frappe (un zéro de trop) avant d'ouvrir le paiement.
_MAX_FREE_AMOUNT = Decimal("10000")
# Durée de vie d'une inscription en attente de paiement jamais menée à terme
# (ni payée, ni relancée) — purgée au-delà (voir `_prune_stale_pending`),
# pour ne pas garder indéfiniment des coordonnées de bénévoles qui n'ont
@@ -268,6 +279,7 @@ def create_payment_option(
amount=payload.amount,
order=payload.order,
requires_mecenat_info=payload.requires_mecenat_info,
free_amount=payload.free_amount,
)
session.add(option)
try:
@@ -427,6 +439,7 @@ def get_public_form(
label=o.label,
amount=o.amount,
requires_mecenat_info=o.requires_mecenat_info,
free_amount=o.free_amount,
)
for o in options
],
@@ -528,6 +541,31 @@ def _parse_id_list(raw: str | None) -> list[uuid.UUID]:
) from exc
def _free_amount(raw: str | None, *, minimum: float) -> float:
"""Montant saisi par le bénévole pour un type de paiement à montant
libre : virgule ou point décimal, au plus 2 décimales, entre `minimum`
(le montant configuré sur l'option) et `_MAX_FREE_AMOUNT` inclus."""
error = HTTPException(
status_code=422,
detail={
"code": "form.invalid_free_amount",
"params": {"min": float(minimum), "max": float(_MAX_FREE_AMOUNT)},
},
)
try:
value = Decimal((raw or "").strip().replace(",", "."))
except InvalidOperation as exc:
raise error from exc
if (
not value.is_finite()
or value.as_tuple().exponent < -2
or value < Decimal(str(minimum))
or value > _MAX_FREE_AMOUNT
):
raise error
return float(value)
def _check_one_artist_wish_per_day(
session: Session, edition_id: uuid.UUID, artist_ids: list[uuid.UUID]
) -> None:
@@ -565,6 +603,7 @@ async def submit_public_form(
companion_wish_note: str | None = Form(None),
available: bool = Form(False),
payment_option_id: str | None = Form(None),
payment_amount: str | None = Form(None),
position_wish_ids: str | None = Form(None),
artist_wish_ids: str | None = Form(None),
photo: UploadFile | None = File(None),
@@ -643,6 +682,11 @@ async def submit_public_form(
raise HTTPException(
status_code=422, detail={"code": "form.unknown_payment_option"}
)
amount_to_pay = (
_free_amount(payment_amount, minimum=payment_option.amount)
if payment_option is not None and payment_option.free_amount
else payment_option.amount if payment_option is not None else None
)
photo_data = await _read_photo(photo) if FormFieldType.PHOTO in fields else None
@@ -706,7 +750,7 @@ async def submit_public_form(
json.dumps([str(i) for i in artist_ids]) if artist_ids else None
),
payment_label=payment_option.label,
payment_amount=payment_option.amount,
payment_amount=amount_to_pay,
requires_mecenat_info=payment_option.requires_mecenat_info,
)
if photo_data:
@@ -789,6 +833,8 @@ def _promote_pending(
)
session.add(volunteer)
session.flush()
if volunteer.mecenat_address:
assign_receipt_number(session, volunteer)
position_ids = _filter_owned_ids(
session, Position, edition.id, _parse_id_list(pending.position_wish_ids_json)
@@ -822,21 +868,48 @@ def _promote_pending(
session.commit()
if volunteer.email:
# Paiement « mécénat » (adresse postale saisie) : le cerfa rempli part
# en pièce jointe du même email — voir `_send_paid_confirmation`.
receipt = receipt_for(volunteer) if volunteer.mecenat_address else None
subject, body = render_registration_paid_confirmation(
first_name=volunteer.first_name,
label=volunteer.fee_payment_label or "",
amount=volunteer.fee_amount or 0,
with_mecenat_receipt=receipt is not None,
)
background.add_task(
send,
_send_paid_confirmation,
volunteer.email,
subject,
body,
logo=edition_logo(session, edition.id),
receipt=receipt,
)
return volunteer
def _send_paid_confirmation(
to: str,
subject: str,
body: str,
*,
logo: tuple[bytes, str] | None,
receipt: MecenatReceipt | None,
) -> None:
"""Tâche de fond : génère le cerfa (si mécénat) puis envoie. Best effort
comme le reste des emails — un échec de génération du PDF ne doit pas
priver le bénévole de sa confirmation, qui part alors sans pièce jointe
(la coordination garde l'export des reçus pour le lui renvoyer)."""
attachments = []
if receipt is not None:
try:
pdf = generate_mecenat_receipts_pdf([receipt])
attachments.append((pdf, "application/pdf", RECEIPT_FILENAME))
except Exception:
logger.exception("Reçu fiscal mécénat non généré — email envoyé sans pièce jointe")
send(to, subject, body, logo=logo, attachments=attachments)
def _apply_pending_checkout_result(
session: Session,
edition: Edition,
+5
View File
@@ -537,6 +537,7 @@ class FormPaymentOptionRead(ApiModel):
amount: float
order: int
requires_mecenat_info: bool
free_amount: bool
created_at: dt.datetime
updated_at: dt.datetime
@@ -546,6 +547,7 @@ class FormPaymentOptionCreate(ApiModel):
amount: float
order: int = 0
requires_mecenat_info: bool = False
free_amount: bool = False
class FormPaymentOptionUpdate(ApiModel):
@@ -553,6 +555,7 @@ class FormPaymentOptionUpdate(ApiModel):
amount: float | None = None
order: int | None = None
requires_mecenat_info: bool | None = None
free_amount: bool | None = None
class PublicPositionOption(ApiModel):
@@ -572,8 +575,10 @@ class PublicPaymentOption(ApiModel):
id: uuid.UUID
label: str
# Montant fixe, ou minimum si `free_amount`.
amount: float
requires_mecenat_info: bool
free_amount: bool
class PublicFormConfig(ApiModel):
+10 -14
View File
@@ -30,7 +30,7 @@ from app.auth.deps import (
)
from app.db import get_session
from app.exporting import generate_volunteers_xlsx
from app.mecenat import MecenatReceipt, generate_mecenat_receipts_pdf
from app.mecenat import assign_receipt_number, generate_mecenat_receipts_pdf, receipt_for
from app.models import (
Account,
Artist,
@@ -247,19 +247,15 @@ def export_mecenat_receipts(
status_code=422, detail={"code": "mecenat.no_receipts"}
)
receipts = [
MecenatReceipt(
last_name=v.last_name,
first_name=v.first_name,
address=v.mecenat_address or "",
postal_code=v.mecenat_postal_code or "",
city=v.mecenat_city or "",
country=v.mecenat_country or "",
amount=v.fee_amount or 0.0,
payment_date=(v.fee_paid_at or v.created_at).date(),
)
for v in volunteers
]
# Bénévoles payés avant la numérotation (ou jamais exportés) : numéro
# attribué maintenant, dans l'ordre des paiements, puis figé.
unnumbered = [v for v in volunteers if not v.mecenat_receipt_number]
if unnumbered:
for v in sorted(unnumbered, key=lambda v: v.fee_paid_at or v.created_at):
assign_receipt_number(session, v)
session.commit()
receipts = [receipt_for(v) for v in volunteers]
content = generate_mecenat_receipts_pdf(receipts)
return Response(
content=content,
+26 -5
View File
@@ -8,7 +8,7 @@ Pour diagnostiquer : `python -m app.email.check <destinataire>`."""
import logging
import smtplib
import ssl
from collections.abc import Iterable
from collections.abc import Iterable, Sequence
from email.message import EmailMessage
from app.config import Settings, get_settings
@@ -33,8 +33,17 @@ def _smtp_error_summary(exc: smtplib.SMTPException) -> str:
return exc.__class__.__name__
# (octets, content-type, nom de fichier) d'une pièce jointe.
Attachment = tuple[bytes, str, str]
def build_message(
to: str, subject: str, body: str, *, logo: tuple[bytes, str] | None = None
to: str,
subject: str,
body: str,
*,
logo: tuple[bytes, str] | None = None,
attachments: Sequence[Attachment] = (),
) -> EmailMessage:
"""`logo` = (octets, content-type) (voir `app/email/branding.py:
edition_logo`) : ajoute une version HTML au thème de l'appli
@@ -43,7 +52,9 @@ def build_message(
HTML. Le logo, s'il y en a un, est intégré en pièce jointe « inline »
(Content-ID) référencée par la partie HTML — jamais une URL externe, qui
aurait exposé le jeton d'accès du formulaire public à quiconque lit la
source du message (voir `app/email/branding.py`)."""
source du message (voir `app/email/branding.py`). `attachments` : vraies
pièces jointes (ex. reçu fiscal mécénat), ajoutées après le logo — le
message devient alors `multipart/mixed`."""
message = EmailMessage()
message["From"] = get_settings().smtp_from
message["To"] = to
@@ -60,6 +71,9 @@ def build_message(
html_part.add_related(
content, maintype or "image", subtype or "png", cid=f"<{LOGO_CID}>"
)
for content, content_type, filename in attachments:
maintype, _, subtype = content_type.partition("/")
message.add_attachment(content, maintype=maintype, subtype=subtype, filename=filename)
return message
@@ -131,8 +145,15 @@ def deliver_many(messages: list[EmailMessage]) -> None:
logger.info("Emails remis : %d/%d", sent, len(messages))
def send(to: str, subject: str, body: str, *, logo: tuple[bytes, str] | None = None) -> None:
deliver_many([build_message(to, subject, body, logo=logo)])
def send(
to: str,
subject: str,
body: str,
*,
logo: tuple[bytes, str] | None = None,
attachments: Sequence[Attachment] = (),
) -> None:
deliver_many([build_message(to, subject, body, logo=logo, attachments=attachments)])
def send_many(
+7 -6
View File
@@ -32,22 +32,23 @@ def render_registration_confirmation(*, first_name: str) -> tuple[str, str]:
def render_registration_paid_confirmation(
*, first_name: str, label: str, amount: float
*, first_name: str, label: str, amount: float, with_mecenat_receipt: bool = False
) -> tuple[str, str]:
"""Inscription prise en compte **et** paiement en ligne confirmé — envoyé
une fois seulement, au moment de la promotion (`app/api/forms.py:
_promote_pending`). Sert de reçu (montant, type de paiement) en
l'absence de confirmation documentée que l'API SumUp Hosted Checkout en
envoie un elle-même."""
return (
t("email.registration_confirmation_paid.subject"),
t(
envoie un elle-même. `with_mecenat_receipt` : le cerfa rempli est joint
(`_promote_pending`), le corps l'annonce."""
body = t(
"email.registration_confirmation_paid.body",
prenom=first_name,
label=label,
montant=amount,
),
)
if with_mecenat_receipt:
body += "\n\n" + t("email.registration_confirmation_paid.mecenat_receipt_note")
return t("email.registration_confirmation_paid.subject"), body
def _fill(template: str, values: dict[str, object]) -> str:
+2
View File
@@ -53,6 +53,7 @@
"email.registration_confirmation.body": "Bonjour {prenom},\n\nTon inscription comme bénévole a bien été enregistrée. La coordination du festival prendra contact avec toi si besoin.\n\nÀ bientôt !",
"email.registration_confirmation_paid.subject": "Ton inscription bénévole est confirmée — paiement reçu",
"email.registration_confirmation_paid.body": "Bonjour {prenom},\n\nTon inscription comme bénévole a bien été enregistrée, et nous confirmons la réception de ton paiement :\n\n{label} — {montant} €\n\nLa coordination du festival prendra contact avec toi si besoin.\n\nÀ bientôt !",
"email.registration_confirmation_paid.mecenat_receipt_note": "Tu trouveras en pièce jointe ton reçu fiscal au titre des dons (cerfa n° 11580*05), à conserver pour ta déclaration de revenus.",
"email.planning.default_subject": "Ton planning bénévole",
"email.planning.default_body": "Bonjour {prenom},\n\nTon planning a été mis à jour : {nb_creneaux} créneau(x), soit {nb_heures} h au total.\n\n{creneaux}\n\nÀ très vite sur le festival !",
"email.planning.no_shifts": "Aucun créneau ne t'est affecté pour le moment.",
@@ -87,6 +88,7 @@
"form.logo_empty": "Le logo est vide.",
"form.logo_too_large": "Le logo est trop volumineux.",
"form.invalid_sumup_amount": "Le montant doit être supérieur à 0.",
"form.invalid_free_amount": "Le montant doit être compris entre {min} € et {max} €, avec au plus deux décimales.",
"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.pending_already_paid": "Cette inscription est déjà payée.",
+8 -1
View File
@@ -1,3 +1,10 @@
from app.mecenat.pdf import MecenatReceipt, generate_mecenat_receipts_pdf
from app.mecenat.receipts import RECEIPT_FILENAME, assign_receipt_number, receipt_for
__all__ = ["MecenatReceipt", "generate_mecenat_receipts_pdf"]
__all__ = [
"RECEIPT_FILENAME",
"MecenatReceipt",
"assign_receipt_number",
"generate_mecenat_receipts_pdf",
"receipt_for",
]
Binary file not shown.
+41 -11
View File
@@ -7,9 +7,9 @@ fichier) n'est pas un formulaire PDF interactif (aucun champ AcroForm) — les
informations du donateur, du montant et de la date sont donc dessinées
par-dessus la page 2 du modèle, aux coordonnées mesurées sur ce document
précis (mise en page fixe d'un cerfa officiel, jamais destinée à changer).
Les informations de l'organisme bénéficiaire (page 1, encadré « Organisme
bénéficiaire ») ne sont volontairement pas renseignées : à compléter à la
main par la coordination avant remise au bénévole.
Les informations de l'organisme bénéficiaire (page 1), les cases articles
200/978 et la signature (page 2) sont pré-remplies dans le modèle lui-même
par l'association — rien à dessiner ici.
"""
import datetime as dt
@@ -54,7 +54,11 @@ _CITY = _Field(x=232, bottom=295.5, max_width=310, font_size=9)
_COUNTRY = _Field(x=67, bottom=310.5, max_width=125, font_size=9)
_AMOUNT = _Field(x=44, bottom=365.2, max_width=110, font_size=9)
_AMOUNT_IN_WORDS = _Field(x=356, bottom=363.1, max_width=190, font_size=8)
_PAYMENT_DATE = _Field(x=184, bottom=380.9, max_width=76, font_size=9)
_PAYMENT_DATE = _Field(x=184, bottom=385, max_width=76, font_size=9)
# Page 1 : case « Numéro d'ordre du reçu » (x 418,7 → 566,8, haut 111 → bas
# 128,2), texte centré dedans (`x` = centre de la case).
_RECEIPT_NUMBER = _Field(x=492.75, bottom=124.0, max_width=140, font_size=11, bold=True)
_CERFA_DATE = _Field(x=320, bottom=640, max_width=76, font_size=9)
@dataclass(frozen=True)
@@ -71,6 +75,7 @@ class MecenatReceipt:
country: str
amount: float
payment_date: dt.date
number: str = ""
def _fitted_font_size(text: str, max_width: float, font_size: float, bold: bool) -> float:
@@ -93,6 +98,31 @@ def _draw(c: canvas.Canvas, text: str, field: _Field) -> None:
c.drawString(field.x, _PAGE_H - field.bottom + _NUDGE, text)
def _draw_centered(c: canvas.Canvas, text: str, field: _Field) -> None:
"""Comme `_draw`, mais `field.x` est le centre de la zone (case encadrée,
pas une ligne de pointillés : pas de `_NUDGE`)."""
if not text:
return
font_size = _fitted_font_size(text, field.max_width, field.font_size, field.bold)
c.setFont("Helvetica-Bold" if field.bold else "Helvetica", font_size)
c.setFillColor(black)
c.drawCentredString(field.x, _PAGE_H - field.bottom, text)
def _finish(c: canvas.Canvas, buffer: io.BytesIO) -> PageObject:
c.showPage()
c.save()
buffer.seek(0)
return PdfReader(buffer).pages[0]
def _organisation_overlay(receipt: MecenatReceipt) -> PageObject:
buffer = io.BytesIO()
c = canvas.Canvas(buffer, pagesize=(_PAGE_W, _PAGE_H))
_draw_centered(c, receipt.number, _RECEIPT_NUMBER)
return _finish(c, buffer)
def _overlay_page(receipt: MecenatReceipt) -> PageObject:
buffer = io.BytesIO()
c = canvas.Canvas(buffer, pagesize=(_PAGE_W, _PAGE_H))
@@ -105,15 +135,14 @@ def _overlay_page(receipt: MecenatReceipt) -> PageObject:
_draw(c, f"{receipt.amount:.2f}".replace(".", ","), _AMOUNT)
_draw(c, amount_in_words(receipt.amount), _AMOUNT_IN_WORDS)
_draw(c, receipt.payment_date.strftime("%d/%m/%Y"), _PAYMENT_DATE)
c.showPage()
c.save()
buffer.seek(0)
return PdfReader(buffer).pages[0]
_draw(c, receipt.payment_date.strftime("%d/%m/%Y"), _CERFA_DATE)
return _finish(c, buffer)
def generate_mecenat_receipts_pdf(receipts: list[MecenatReceipt]) -> bytes:
"""Un reçu = les 2 pages du cerfa officiel, page 2 complétée avec les
informations du donateur, du montant et de la date — voir le docstring
"""Un reçu = les 2 pages du cerfa officiel, page 1 complétée avec le
numéro d'ordre, page 2 avec les informations du donateur, du montant et
de la date — voir le docstring
du module. Retourne les octets du PDF concaténant tous les reçus, un
bénévole après l'autre. Le modèle est relu à chaque itération : `merge_page`
mutant la page en place, réutiliser le même objet d'un tour à l'autre
@@ -121,7 +150,8 @@ def generate_mecenat_receipts_pdf(receipts: list[MecenatReceipt]) -> bytes:
writer = PdfWriter()
for receipt in receipts:
base = PdfReader(_TEMPLATE_PATH)
writer.add_page(base.pages[0])
organisation_page = writer.add_page(base.pages[0])
organisation_page.merge_page(_organisation_overlay(receipt))
# `add_page` renvoie la page telle qu'attachée au writer — fusionner
# l'overlay sur ce retour plutôt que sur `base.pages[1]` (page encore
# détachée d'un writer, `merge_page` dépréciée dans ce cas).
+56
View File
@@ -0,0 +1,56 @@
"""Passage d'un `Volunteer` à un `MecenatReceipt` — partagé par l'export
coordination (`app/api/volunteers.py:export_mecenat_receipts`) et l'envoi du
reçu au bénévole à la confirmation de son paiement (`app/api/forms.py:
_promote_pending`) — et attribution de son numéro d'ordre."""
from sqlalchemy import func, text
from sqlmodel import Session, select
from app.mecenat.pdf import MecenatReceipt
from app.models import Volunteer
RECEIPT_FILENAME = "recu-fiscal-mecenat.pdf"
# Verrou applicatif PostgreSQL (`pg_advisory_xact_lock`) sérialisant
# l'attribution des numéros : le webhook SumUp et le sondage du navigateur
# peuvent confirmer deux paiements en même temps — sans lui, les deux
# liraient le même maximum et se disputeraient le même numéro. Libéré
# automatiquement à la fin de la transaction. Valeur arbitraire, propre à
# cet usage.
_NUMBERING_LOCK = 11580_05
def assign_receipt_number(session: Session, volunteer: Volunteer) -> None:
"""Attribue (sans commit) le prochain numéro de l'année du paiement :
`AAAA` + rang sur 5 chiffres, à la suite du plus grand déjà attribué
cette année-là, toutes éditions confondues (une seule numérotation pour
l'association). Ne fait rien si le bénévole en a déjà un — un numéro
n'est jamais réattribué."""
if volunteer.mecenat_receipt_number:
return
session.exec(text("SELECT pg_advisory_xact_lock(:key)").bindparams(key=_NUMBERING_LOCK))
year = (volunteer.fee_paid_at or volunteer.created_at).year
prefix = str(year)
last = session.exec(
select(func.max(Volunteer.mecenat_receipt_number)).where(
Volunteer.mecenat_receipt_number.startswith(prefix)
)
).one()
rank = int(last[len(prefix):]) + 1 if last else 1
volunteer.mecenat_receipt_number = f"{prefix}{rank:05d}"
session.add(volunteer)
session.flush()
def receipt_for(volunteer: Volunteer) -> MecenatReceipt:
return MecenatReceipt(
last_name=volunteer.last_name,
first_name=volunteer.first_name,
address=volunteer.mecenat_address or "",
postal_code=volunteer.mecenat_postal_code or "",
city=volunteer.mecenat_city or "",
country=volunteer.mecenat_country or "",
amount=volunteer.fee_amount or 0.0,
payment_date=(volunteer.fee_paid_at or volunteer.created_at).date(),
number=volunteer.mecenat_receipt_number or "",
)
@@ -0,0 +1,35 @@
"""Numéro d'ordre des reçus fiscaux mécénat
Revision ID: 0020_mecenat_receipt_number
Revises: 0019_form_access_token
Create Date: 2026-10-03
`volunteer.mecenat_receipt_number` : numéro unique du cerfa (`AAAA` + rang
sur 5 chiffres, ex. `202600001`) — voir `app/mecenat/receipts.py`. Les
bénévoles déjà payés en mécénat n'en reçoivent pas ici : ils sont numérotés
au premier export des reçus, dans l'ordre de leurs paiements.
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0020_mecenat_receipt_number"
down_revision: str | None = "0019_form_access_token"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column(
"volunteer", sa.Column("mecenat_receipt_number", sa.String(), nullable=True)
)
op.create_unique_constraint(
"volunteer_mecenat_receipt_number_key", "volunteer", ["mecenat_receipt_number"]
)
def downgrade() -> None:
op.drop_constraint("volunteer_mecenat_receipt_number_key", "volunteer", type_="unique")
op.drop_column("volunteer", "mecenat_receipt_number")
@@ -0,0 +1,32 @@
"""Type de paiement à montant libre
Revision ID: 0021_payment_option_free_amount
Revises: 0020_mecenat_receipt_number
Create Date: 2026-10-03
`form_payment_option.free_amount` : le bénévole saisit lui-même son montant
sur le formulaire public, `amount` en devient le minimum — voir
`app/api/forms.py:_free_amount`. Les types de paiement existants restent à
montant fixe.
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0021_payment_option_free_amount"
down_revision: str | None = "0020_mecenat_receipt_number"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column(
"form_payment_option",
sa.Column("free_amount", sa.Boolean(), nullable=False, server_default="false"),
)
def downgrade() -> None:
op.drop_column("form_payment_option", "free_amount")
+12
View File
@@ -221,6 +221,11 @@ class Volunteer(TimestampMixin, table=True):
mecenat_city: str | None = None
mecenat_postal_code: str | None = None
mecenat_country: str | None = None
# Numéro d'ordre du reçu fiscal (cerfa) : année du paiement + rang sur 5
# chiffres (`202600001`), attribué une seule fois — à la confirmation du
# paiement ou, à défaut, au premier export — puis figé, pour qu'un reçu
# regénéré garde son numéro. Voir `app/mecenat/receipts.py`.
mecenat_receipt_number: str | None = Field(default=None, unique=True)
wished_artists: list[Artist] = Relationship(
back_populates="fans",
@@ -571,6 +576,13 @@ class FormPaymentOption(TimestampMixin, table=True):
default=False,
sa_column=Column(Boolean, nullable=False, server_default="false"),
)
# Montant libre : le bénévole saisit lui-même son montant sur le
# formulaire public ; `amount` en est alors le minimum (voir
# `app/api/forms.py:_free_amount`).
free_amount: bool = Field(
default=False,
sa_column=Column(Boolean, nullable=False, server_default="false"),
)
class PendingFormSubmission(TimestampMixin, table=True):
+58 -2
View File
@@ -454,13 +454,13 @@ def test_export_volunteers_is_coordinator_only(edition, make_account, as_account
assert resp.status_code == 403
def _mark_mecenat_paid(session, volunteer, *, amount=50.0):
def _mark_mecenat_paid(session, volunteer, *, amount=50.0, paid_at=dt.datetime(2026, 3, 5, 10, 0)):
"""Simule l'état d'un bénévole ayant payé un type de paiement « mécénat »
(voir `app/api/forms.py:_promote_pending`) — `mecenat_address` est le
marqueur qui distingue ce chemin d'un simple paiement classique."""
volunteer.fee_payment_status = FeePaymentStatus.PAID
volunteer.fee_amount = amount
volunteer.fee_paid_at = dt.datetime(2026, 3, 5, 10, 0)
volunteer.fee_paid_at = paid_at
volunteer.mecenat_address = "12 rue des Lilas"
volunteer.mecenat_city = "Paris"
volunteer.mecenat_postal_code = "75020"
@@ -512,6 +512,62 @@ def test_mecenat_receipts_pdf_includes_only_paid_mecenat_donors(
assert "Petit" not in donor_page_text
def test_mecenat_receipts_are_numbered_by_payment_year_and_order(client, edition, session):
later = make_volunteer(session, edition, first_name="Alex", last_name="Martin")
_mark_mecenat_paid(session, later, paid_at=dt.datetime(2026, 4, 1, 9, 0))
earlier = make_volunteer(session, edition, first_name="Sam", last_name="Petit")
_mark_mecenat_paid(session, earlier, paid_at=dt.datetime(2026, 3, 1, 9, 0))
resp = client.get(_url(edition, "/mecenat-receipts"))
assert resp.status_code == 200
session.refresh(later)
session.refresh(earlier)
# Attribués dans l'ordre des paiements, pas de l'ordre alphabétique.
assert earlier.mecenat_receipt_number == "202600001"
assert later.mecenat_receipt_number == "202600002"
reader = PdfReader(io.BytesIO(resp.content))
# Trié par nom : Martin (pages 1-2) puis Petit (pages 3-4).
assert "202600002" in reader.pages[0].extract_text()
assert "202600001" in reader.pages[2].extract_text()
def test_mecenat_receipt_number_is_stable_across_regenerations(client, edition, session):
volunteer = make_volunteer(session, edition, first_name="Alex", last_name="Martin")
_mark_mecenat_paid(session, volunteer)
client.get(_url(edition, "/mecenat-receipts"))
session.refresh(volunteer)
first = volunteer.mecenat_receipt_number
newcomer = make_volunteer(session, edition, first_name="Sam", last_name="Petit")
_mark_mecenat_paid(session, newcomer)
client.get(_url(edition, "/mecenat-receipts"))
session.refresh(volunteer)
session.refresh(newcomer)
assert volunteer.mecenat_receipt_number == first
assert newcomer.mecenat_receipt_number == "202600002"
def test_mecenat_receipt_numbers_continue_the_existing_sequence(client, edition, session):
numbered = make_volunteer(session, edition, first_name="Alex", last_name="Martin")
_mark_mecenat_paid(session, numbered)
numbered.mecenat_receipt_number = "202600041"
session.add(numbered)
session.commit()
other = make_volunteer(session, edition, first_name="Sam", last_name="Petit")
_mark_mecenat_paid(session, other)
# Une autre année repart à 1.
next_year = make_volunteer(session, edition, first_name="Jo", last_name="Dupuis")
_mark_mecenat_paid(session, next_year, paid_at=dt.datetime(2027, 1, 2, 9, 0))
client.get(_url(edition, "/mecenat-receipts"))
session.refresh(other)
session.refresh(next_year)
assert other.mecenat_receipt_number == "202600042"
assert next_year.mecenat_receipt_number == "202700001"
def test_mecenat_receipts_is_422_without_any_paid_donor(client, edition):
resp = client.get(_url(edition, "/mecenat-receipts"))
assert resp.status_code == 422
+172 -1
View File
@@ -7,9 +7,10 @@ import io
import uuid
import pytest
from pypdf import PdfReader
from app.auth.rate_limit import form_submission_limiter, pending_status_limiter
from app.models import FormTemplate, PendingFormSubmission, Role, local_now
from app.models import FormTemplate, PendingFormSubmission, Role, Volunteer, local_now
from app.sumup import SumUpCheckout
from tests.factories import make_artist, make_position
from tests.mail import plain_text
@@ -912,6 +913,91 @@ def test_choosing_a_mecenat_option_requires_the_address_step_first(
assert fiche["mecenat_country"] == "France"
def _attachments(message) -> list:
return [
part
for part in message.iter_attachments()
if part.get_content_disposition() == "attachment"
]
def test_mecenat_payment_emails_the_filled_cerfa(
raw_client, client, edition, session, monkeypatch, outbox
):
client.put(_form_url(edition, "/fields"), json={"fields": ["WILLING_TO_PAY_FEE", "EMAIL"]})
client.patch(
_form_url(edition), json={"sumup_merchant_code": _MERCHANT, "sumup_api_key": _API_KEY}
)
option_id = client.post(
_form_url(edition, "/payment-options"),
json={"label": "Mécénat", "amount": 50, "requires_mecenat_info": True},
).json()["id"]
token = _submit(
raw_client, edition,
first_name="Alex", last_name="Martin", hcaptcha_token="x",
email="alex@example.org",
payment_option_id=option_id,
).json()["pending_token"]
monkeypatch.setattr(
"app.api.forms.create_checkout",
lambda **kw: SumUpCheckout(id="co_don", status="PENDING", hosted_checkout_url="https://don"),
)
raw_client.post(
_form_url(edition, f"/public/pending/{token}/mecenat-info"),
json={
"address": "1 rue du Festival",
"city": "Rennes",
"postal_code": "35000",
"country": "France",
},
)
assert outbox == [] # pas encore payé : ni email ni reçu
_pay(raw_client, edition, monkeypatch, checkout_id="co_don")
assert len(outbox) == 1
attachments = _attachments(outbox[0])
assert len(attachments) == 1
assert attachments[0].get_content_type() == "application/pdf"
assert attachments[0].get_filename().endswith(".pdf")
assert attachments[0].get_content().startswith(b"%PDF")
assert "reçu fiscal" in plain_text(outbox[0])
# Numéro d'ordre attribué à la promotion, et imprimé sur le cerfa joint.
volunteer_id = _pending_status(raw_client, edition, token)["volunteer_id"]
volunteer = session.get(Volunteer, uuid.UUID(volunteer_id))
expected = f"{volunteer.fee_paid_at.year}00001"
assert volunteer.mecenat_receipt_number == expected
reader = PdfReader(io.BytesIO(attachments[0].get_content()))
assert expected in reader.pages[0].extract_text()
def test_classic_payment_email_has_no_cerfa(raw_client, client, edition, monkeypatch, outbox):
client.put(_form_url(edition, "/fields"), json={"fields": ["WILLING_TO_PAY_FEE", "EMAIL"]})
client.patch(
_form_url(edition), json={"sumup_merchant_code": _MERCHANT, "sumup_api_key": _API_KEY}
)
option_id = client.post(
_form_url(edition, "/payment-options"),
json={"label": "Cotisation classique", "amount": 12.5},
).json()["id"]
monkeypatch.setattr(
"app.api.forms.create_checkout",
lambda **kw: SumUpCheckout(id="co_1", status="PENDING", hosted_checkout_url="https://x"),
)
_submit(
raw_client, edition,
first_name="Alex", last_name="Martin", hcaptcha_token="x",
email="alex@example.org",
payment_option_id=option_id,
)
_pay(raw_client, edition, monkeypatch, checkout_id="co_1")
assert len(outbox) == 1
assert _attachments(outbox[0]) == []
assert "reçu fiscal" not in plain_text(outbox[0])
def test_mecenat_info_requires_every_field(raw_client, client, edition, monkeypatch):
client.put(_form_url(edition, "/fields"), json={"fields": ["WILLING_TO_PAY_FEE"]})
client.patch(
@@ -1002,3 +1088,88 @@ def test_pending_status_polling_is_rate_limited_per_token(
for _ in range(pending_status_limiter.max_attempts):
pending_status_limiter.hit(token)
assert raw_client.get(_form_url(edition, f"/public/pending/{token}")).status_code == 429
def _configure_free_amount(client, edition, minimum=10):
"""Type de paiement « montant libre » : `amount` est le minimum."""
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": "Don libre", "amount": minimum, "free_amount": True},
)
assert resp.status_code == 201
assert resp.json()["free_amount"] is True
return resp.json()["id"]
def test_free_amount_option_is_exposed_on_the_public_form(raw_client, client, edition):
_configure_free_amount(client, edition)
config = raw_client.get(_public(edition, "/public")).json()
assert config["payment_options"][0]["free_amount"] is True
assert config["payment_options"][0]["amount"] == 10
def test_free_amount_uses_the_amount_chosen_by_the_volunteer(
raw_client, client, edition, monkeypatch
):
option_id = _configure_free_amount(client, edition)
checkouts = []
def fake_checkout(**kw):
checkouts.append(kw)
return SumUpCheckout(id="co_free", status="PENDING", hosted_checkout_url="https://x")
monkeypatch.setattr("app.api.forms.create_checkout", fake_checkout)
resp = _submit(
raw_client, edition,
first_name="Alex", last_name="Martin", hcaptcha_token="x",
payment_option_id=option_id, payment_amount="75,50",
)
assert resp.status_code == 201
assert checkouts[0]["amount"] == 75.5
token = resp.json()["pending_token"]
_pay(raw_client, edition, monkeypatch, checkout_id="co_free")
volunteer_id = _pending_status(raw_client, edition, token)["volunteer_id"]
fiche = client.get(f"/editions/{edition.id}/volunteers/{volunteer_id}").json()
assert fiche["fee_amount"] == 75.5
assert fiche["fee_payment_label"] == "Don libre"
@pytest.mark.parametrize("amount", [None, "", "abc", "9.99", "0", "-5", "10000.01", "12.345"])
def test_free_amount_rejects_missing_or_out_of_range_amounts(
raw_client, client, edition, monkeypatch, amount
):
option_id = _configure_free_amount(client, edition)
monkeypatch.setattr("app.api.forms.create_checkout", lambda **kw: None)
fields = dict(
first_name="Alex", last_name="Martin", hcaptcha_token="x", payment_option_id=option_id
)
if amount is not None:
fields["payment_amount"] = amount
resp = _submit(raw_client, edition, **fields)
assert resp.status_code == 422
detail = resp.json()["detail"]
assert detail["code"] == "form.invalid_free_amount"
assert detail["params"] == {"min": 10.0, "max": 10000.0}
def test_fixed_amount_option_ignores_any_amount_sent(raw_client, client, edition, monkeypatch):
option_id = _configure_sumup(client, edition, amount=12.5)
checkouts = []
def fake_checkout(**kw):
checkouts.append(kw)
return SumUpCheckout(id="co_fixed", status="PENDING", hosted_checkout_url="https://x")
monkeypatch.setattr("app.api.forms.create_checkout", fake_checkout)
resp = _submit(
raw_client, edition,
first_name="Alex", last_name="Martin", hcaptcha_token="x",
payment_option_id=option_id, payment_amount="1",
)
assert resp.status_code == 201
assert checkouts[0]["amount"] == 12.5
+1
View File
@@ -312,6 +312,7 @@ export function createHttpApi(baseUrl: string): PlanningApi {
if (body.companion_wish_note) form.append('companion_wish_note', body.companion_wish_note);
if (body.available !== undefined) form.append('available', String(body.available));
if (body.payment_option_id) form.append('payment_option_id', body.payment_option_id);
if (body.payment_amount) form.append('payment_amount', body.payment_amount);
if (body.position_wish_ids) {
form.append('position_wish_ids', JSON.stringify(body.position_wish_ids));
}
+16 -1
View File
@@ -116,6 +116,16 @@ const imageSizeMm = async (file: File): Promise<{ width_mm: number; height_mm: n
// Approche de `str.title()` (Python) utilisé côté back-end à la soumission
// du formulaire public — capitalise chaque mot (« - » compris).
/** Miroir de `app/api/forms.py:_free_amount` (montant libre). */
const freeAmount = (raw: string | undefined, minimum: number): number => {
const text = (raw ?? '').trim().replace(',', '.');
const value = Number(text);
if (!/^\d+(\.\d{1,2})?$/.test(text) || value < minimum || value > 10000) {
throw new ApiError(422, 'form.invalid_free_amount');
}
return value;
};
const titleCase = (s: string): string =>
s.toLowerCase().replace(/\b\p{L}/gu, (c) => c.toUpperCase());
@@ -1647,6 +1657,7 @@ export function createMockApi(): PlanningApi {
label: o.label,
amount: o.amount,
requires_mecenat_info: o.requires_mecenat_info,
free_amount: o.free_amount,
})),
});
},
@@ -1666,6 +1677,7 @@ export function createMockApi(): PlanningApi {
amount: body.amount,
order: body.order ?? 0,
requires_mecenat_info: body.requires_mecenat_info ?? false,
free_amount: body.free_amount ?? false,
created_at: now,
updated_at: now,
};
@@ -1691,6 +1703,7 @@ export function createMockApi(): PlanningApi {
...(body.requires_mecenat_info !== undefined
? { requires_mecenat_info: body.requires_mecenat_info }
: {}),
...(body.free_amount !== undefined ? { free_amount: body.free_amount } : {}),
updated_at: touch(),
});
return delay<FormPaymentOptionRead>(option);
@@ -1802,7 +1815,9 @@ export function createMockApi(): PlanningApi {
artistIds,
photo,
paymentLabel: paymentOption.label,
paymentAmount: paymentOption.amount,
paymentAmount: paymentOption.free_amount
? freeAmount(body.payment_amount, paymentOption.amount)
: paymentOption.amount,
requiresMecenatInfo: paymentOption.requires_mecenat_info,
mecenatAddress: null,
mecenatCity: null,
+8
View File
@@ -618,6 +618,8 @@ export interface FormPaymentOptionRead {
/** Type « mécénat » : le bénévole passe par une étape d'adresse postale
* avant le paiement (reçu fiscal / cerfa). */
requires_mecenat_info: boolean;
/** Montant libre : le bénévole saisit son montant, `amount` = minimum. */
free_amount: boolean;
created_at: string;
updated_at: string;
}
@@ -627,6 +629,7 @@ export interface FormPaymentOptionCreate {
amount: number;
order?: number;
requires_mecenat_info?: boolean;
free_amount?: boolean;
}
export interface FormPaymentOptionUpdate {
@@ -634,6 +637,7 @@ export interface FormPaymentOptionUpdate {
amount?: number;
order?: number;
requires_mecenat_info?: boolean;
free_amount?: boolean;
}
export interface PublicPositionOption {
@@ -652,8 +656,10 @@ export interface PublicArtistOption {
export interface PublicPaymentOption {
id: string;
label: string;
/** Montant fixe, ou minimum si `free_amount`. */
amount: number;
requires_mecenat_info: boolean;
free_amount: boolean;
}
/** Tout ce qu'il faut pour afficher le formulaire public — jamais plus. */
@@ -719,6 +725,8 @@ export interface PublicFormSubmission {
/** Type de paiement choisi (voir `PublicPaymentOption`) — absent = « je
* paierai sur place » (aucun paiement en ligne). */
payment_option_id?: string;
/** Montant saisi, pour un type de paiement à montant libre seulement. */
payment_amount?: string;
position_wish_ids?: string[];
artist_wish_ids?: string[];
photo?: File | null;
+11 -1
View File
@@ -225,10 +225,20 @@ export function HelpTip({
);
}
export function FieldLabel({ children }: { children: ReactNode }) {
/** Astérisque rouge des champs obligatoires (purement visuel : la règle vit ailleurs). */
export function RequiredMark() {
return (
<span aria-hidden="true" className="ml-[3px] text-red-600">
*
</span>
);
}
export function FieldLabel({ children, required }: { children: ReactNode; required?: boolean }) {
return (
<span className="mb-[5px] block font-display text-[9px] font-extrabold uppercase tracking-widest">
{children}
{required ? <RequiredMark /> : null}
</span>
);
}
+8 -1
View File
@@ -390,7 +390,7 @@
"config.vol.feeStatus.failed": "Cotisation : paiement échoué",
"config.vol.feeStatus.expired": "Cotisation : lien de paiement expiré",
"config.vol.mecenatAddress": "Adresse (cerfa)",
"form.field.photo": "Une magnifique photo de toi :",
"form.field.photo": "Une magnifique photo de toi pour ton badge :",
"form.field.comment": "Commentaire",
"form.link.title": "Lien du formulaire",
"form.link.hint": "À partager aux bénévoles potentiels — n'importe qui muni du lien peut s'inscrire, mais lui seul : sans ce lien précis, impossible de tomber sur le formulaire.",
@@ -423,6 +423,8 @@
"form.customize.paymentOptions.label": "Libellé (ex. Cotisation classique, Mécénat…)",
"form.customize.paymentOptions.add": "Ajouter",
"form.customize.paymentOptions.mecenat": "Mécénat",
"form.customize.paymentOptions.free": "Montant libre",
"form.customize.paymentOptions.free.help": "Le bénévole choisit lui-même son montant ; le montant indiqué ici devient le minimum.",
"form.customize.paymentOptions.mecenat.help": "Avant de payer, le bénévole passe par une étape d'adresse postale (reçu fiscal / cerfa).",
"publicForm.title": "Inscription bénévole",
@@ -431,11 +433,15 @@
"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",
"publicForm.field.paymentOption": "Cotisation",
"publicForm.paymentOption.from": "{label} — à partir de {amount} €",
"publicForm.freeAmount.label": "Montant de ta contribution (€)",
"publicForm.freeAmount.hint": "Minimum {min} €.",
"publicForm.paymentOption.onSite": "Je paierai sur place",
"publicForm.photo.squareHint": "Recadrée automatiquement en carré.",
"publicForm.sumup.afterSubmitHint": "Un lien de paiement en ligne (SumUp) te sera proposé juste après l'envoi de ton inscription.",
"publicForm.captcha.disabled": "(Vérification anti-robot non configurée sur cette instance.)",
"publicForm.submitting": "Envoi…",
"publicForm.requiredLegend": "champ obligatoire",
"publicForm.submit": "Envoyer mon inscription",
"publicForm.success.title": "Inscription reçue",
"publicForm.success.body": "Merci ! Ton inscription a bien été enregistrée. La coordination du festival prendra contact avec toi.",
@@ -469,6 +475,7 @@
"form.logo_empty": "Le logo est vide.",
"form.logo_too_large": "Le logo est trop volumineux.",
"form.invalid_sumup_amount": "Le montant doit être supérieur à 0.",
"form.invalid_free_amount": "Le montant saisi n'est pas valide (au-dessus du minimum indiqué, 10 000 € au plus, deux décimales au maximum).",
"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.pending_already_paid": "Cette inscription est déjà payée.",
+27
View File
@@ -354,6 +354,7 @@ function PaymentOptionsForm() {
const [label, setLabel] = useState('');
const [amount, setAmount] = useState('');
const [requiresMecenatInfo, setRequiresMecenatInfo] = useState(false);
const [freeAmount, setFreeAmount] = useState(false);
if (options.isLoading) return <div className="font-mono text-[10px] opacity-60">…</div>;
if (options.error) return <ErrorNote error={options.error} />;
@@ -386,6 +387,18 @@ function PaymentOptionsForm() {
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>
<label
className="flex flex-none cursor-pointer items-center gap-1 font-mono text-[9.5px] opacity-70"
title={t('form.customize.paymentOptions.free.help')}
>
<input
type="checkbox"
checked={o.free_amount}
onChange={(e) => update.mutate({ id: o.id, body: { free_amount: e.target.checked } })}
className="h-[13px] w-[13px] accent-ink"
/>
{t('form.customize.paymentOptions.free')}
</label>
<label
className="flex flex-none cursor-pointer items-center gap-1 font-mono text-[9.5px] opacity-70"
title={t('form.customize.paymentOptions.mecenat.help')}
@@ -428,6 +441,18 @@ function PaymentOptionsForm() {
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>
<label
className="flex flex-none cursor-pointer items-center gap-1 font-mono text-[9.5px] opacity-70"
title={t('form.customize.paymentOptions.free.help')}
>
<input
type="checkbox"
checked={freeAmount}
onChange={(e) => setFreeAmount(e.target.checked)}
className="h-[13px] w-[13px] accent-ink"
/>
{t('form.customize.paymentOptions.free')}
</label>
<label
className="flex flex-none cursor-pointer items-center gap-1 font-mono text-[9.5px] opacity-70"
title={t('form.customize.paymentOptions.mecenat.help')}
@@ -450,12 +475,14 @@ function PaymentOptionsForm() {
amount: Number(amount),
order: rows.length,
requires_mecenat_info: requiresMecenatInfo,
free_amount: freeAmount,
},
{
onSuccess: () => {
setLabel('');
setAmount('');
setRequiresMecenatInfo(false);
setFreeAmount(false);
},
},
)
+88 -38
View File
@@ -12,7 +12,7 @@ import {
} from '@/api/hooks';
import type { PublicArtistOption } from '@/api/schema';
import { ErrorNote, Loading } from '@/components/state';
import { Btn, FieldLabel, Panel, PanelHeader, TextInput } from '@/components/ui';
import { Btn, FieldLabel, Panel, PanelHeader, RequiredMark, TextInput } from '@/components/ui';
import { t } from '@/i18n';
import { ENV } from '@/lib/env';
import { longDayLabel } from '@/lib/time';
@@ -107,6 +107,17 @@ async function cropToSquareJpeg(file: File): Promise<File> {
}
}
// Miroir de `app/api/forms.py:_free_amount` — validé ici aussi pour
// signaler l'erreur avant l'envoi, le serveur reste seul juge.
const MAX_FREE_AMOUNT = 10000;
function isValidFreeAmount(raw: string, minimum: number): boolean {
const text = raw.trim().replace(',', '.');
if (!/^\d+(\.\d{1,2})?$/.test(text)) return false;
const value = Number(text);
return value >= minimum && value <= MAX_FREE_AMOUNT;
}
function errorMessage(error: unknown): string {
if (error instanceof ApiError) return t(error.code);
return t('error.generic');
@@ -271,21 +282,21 @@ function MecenatInfoStep({
{t('publicForm.mecenat.hint')}
</p>
<label className="block">
<FieldLabel>{t('publicForm.mecenat.address')}</FieldLabel>
<FieldLabel required>{t('publicForm.mecenat.address')}</FieldLabel>
<TextInput value={address} onChange={setAddress} />
</label>
<div className="grid grid-cols-2 gap-3">
<label className="block">
<FieldLabel>{t('publicForm.mecenat.postalCode')}</FieldLabel>
<FieldLabel required>{t('publicForm.mecenat.postalCode')}</FieldLabel>
<TextInput mono value={postalCode} onChange={setPostalCode} />
</label>
<label className="block">
<FieldLabel>{t('publicForm.mecenat.city')}</FieldLabel>
<FieldLabel required>{t('publicForm.mecenat.city')}</FieldLabel>
<TextInput value={city} onChange={setCity} />
</label>
</div>
<label className="block">
<FieldLabel>{t('publicForm.mecenat.country')}</FieldLabel>
<FieldLabel required>{t('publicForm.mecenat.country')}</FieldLabel>
<TextInput value={country} onChange={setCountry} />
</label>
{error ? (
@@ -354,6 +365,7 @@ function PublicSignupForm({ accessToken }: { accessToken: string }) {
const [available, setAvailable] = useState(false);
const [willingToPayFee, setWillingToPayFee] = useState(false);
const [paymentOptionId, setPaymentOptionId] = useState('');
const [freeAmount, setFreeAmount] = useState('');
const [positionIds, setPositionIds] = useState<string[]>([]);
const [artistByDay, setArtistByDay] = useState<Record<string, string>>({});
const [photo, setPhoto] = useState<File | null>(null);
@@ -455,6 +467,10 @@ function PublicSignupForm({ accessToken }: { accessToken: string }) {
});
};
const selectedOption = config.data.sumup_enabled
? config.data.payment_options.find((o) => o.id === paymentOptionId)
: undefined;
// Tout champ activé par la coordination est obligatoire — le formulaire
// public n'est plus une simple collecte d'infos facultatives.
const canSubmit =
@@ -464,6 +480,7 @@ function PublicSignupForm({ accessToken }: { accessToken: string }) {
(!fields.has('PHONE') || phone.trim().length > 0) &&
(!fields.has('AVAILABLE') || available) &&
(!fields.has('WILLING_TO_PAY_FEE') || config.data.sumup_enabled || willingToPayFee) &&
(!selectedOption?.free_amount || isValidFreeAmount(freeAmount, selectedOption.amount)) &&
(toggleEnabled ? captchaToken.length > 0 : true) &&
!submit.isPending;
@@ -484,6 +501,7 @@ function PublicSignupForm({ accessToken }: { accessToken: string }) {
fields.has('WILLING_TO_PAY_FEE') && config.data.sumup_enabled
? paymentOptionId || undefined
: undefined,
payment_amount: selectedOption?.free_amount ? freeAmount : 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,
@@ -516,27 +534,31 @@ function PublicSignupForm({ accessToken }: { accessToken: string }) {
</p>
) : null}
<form onSubmit={handleSubmit} className="flex flex-col gap-4 p-4">
<p className="font-mono text-[10.5px] opacity-70">
<span className="mr-[3px] text-red-600">*</span>
{t('publicForm.requiredLegend')}
</p>
<div className="grid grid-cols-2 gap-3">
<label className="block">
<FieldLabel>{t('config.vol.field.firstName')}</FieldLabel>
<FieldLabel required>{t('config.vol.field.firstName')}</FieldLabel>
<TextInput value={firstName} onChange={setFirstName} />
</label>
<label className="block">
<FieldLabel>{t('config.vol.field.lastName')}</FieldLabel>
<FieldLabel required>{t('config.vol.field.lastName')}</FieldLabel>
<TextInput value={lastName} onChange={setLastName} />
</label>
</div>
{fields.has('EMAIL') ? (
<label className="block">
<FieldLabel>{t('form.field.email')}</FieldLabel>
<FieldLabel required>{t('form.field.email')}</FieldLabel>
<TextInput mono value={email} onChange={setEmail} />
</label>
) : null}
{fields.has('PHONE') ? (
<label className="block">
<FieldLabel>{t('form.field.phone')}</FieldLabel>
<FieldLabel required>{t('form.field.phone')}</FieldLabel>
<TextInput mono value={phone} onChange={setPhone} />
</label>
) : null}
@@ -607,6 +629,26 @@ function PublicSignupForm({ accessToken }: { accessToken: string }) {
</label>
) : null}
{fields.has('PHOTO') ? (
<label className="block">
<FieldLabel>{t('form.field.photo')}</FieldLabel>
{photoPreviewUrl ? (
<img
src={photoPreviewUrl}
alt=""
className="mb-[8px] h-[180px] w-[180px] border-2 border-ink object-cover shadow-brutal-sm"
/>
) : null}
<input
type="file"
accept="image/*"
capture="environment"
onChange={(e) => handlePhotoChange(e.target.files?.[0] ?? null)}
className="w-full border-2 border-ink bg-cream p-[8px] font-mono text-[11px]"
/>
</label>
) : null}
{fields.has('COMMENT') ? (
<label className="block">
<FieldLabel>{t('form.field.comment')}</FieldLabel>
@@ -627,7 +669,10 @@ function PublicSignupForm({ accessToken }: { accessToken: string }) {
onChange={(e) => setAvailable(e.target.checked)}
className="h-[16px] w-[16px] accent-ink"
/>
<span className="font-mono text-[12px]">{t('publicForm.field.available')}</span>
<span className="font-mono text-[12px]">
{t('publicForm.field.available')}
<RequiredMark />
</span>
</label>
) : null}
@@ -657,14 +702,39 @@ function PublicSignupForm({ accessToken }: { accessToken: string }) {
onChange={() => setPaymentOptionId(o.id)}
className="h-[15px] w-[15px] accent-ink"
/>
{o.label} — {o.amount} €
{o.free_amount
? t('publicForm.paymentOption.from', { label: o.label, amount: o.amount })
: `${o.label} — ${o.amount} €`}
</label>
))}
</div>
{paymentOptionId ? (
{selectedOption?.free_amount ? (
<label className="mt-[8px] block">
<FieldLabel required>{t('publicForm.freeAmount.label')}</FieldLabel>
<input
type="number"
inputMode="decimal"
min={selectedOption.amount}
max={MAX_FREE_AMOUNT}
step="0.01"
value={freeAmount}
onChange={(e) => setFreeAmount(e.target.value)}
placeholder={String(selectedOption.amount)}
className="w-full border-2 border-ink bg-cream p-[9px] font-mono text-[12px]"
/>
<span className="mt-[4px] block font-mono text-[10px] opacity-60">
{t('publicForm.freeAmount.hint', { min: selectedOption.amount })}
</span>
{freeAmount && !isValidFreeAmount(freeAmount, selectedOption.amount) ? (
<span className="mt-[4px] block font-mono text-[10.5px] font-bold text-orange-dark">
{t('form.invalid_free_amount')}
</span>
) : null}
</label>
) : null}
{selectedOption ? (
<p className="mt-[6px] font-mono text-[10.5px] opacity-70">
{config.data.payment_options.find((o) => o.id === paymentOptionId)
?.requires_mecenat_info
{selectedOption.requires_mecenat_info
? t('publicForm.mecenat.afterSubmitHint')
: t('publicForm.sumup.afterSubmitHint')}
</p>
@@ -678,30 +748,10 @@ function PublicSignupForm({ accessToken }: { accessToken: string }) {
onChange={(e) => setWillingToPayFee(e.target.checked)}
className="h-[16px] w-[16px] accent-ink"
/>
<span className="font-mono text-[12px]">{t('publicForm.field.willingToPayFee')}</span>
</label>
) : null}
{fields.has('PHOTO') ? (
<label className="block">
<FieldLabel>{t('form.field.photo')}</FieldLabel>
{photoPreviewUrl ? (
<img
src={photoPreviewUrl}
alt=""
className="mb-[8px] h-[180px] w-[180px] border-2 border-ink object-cover shadow-brutal-sm"
/>
) : null}
<input
type="file"
accept="image/*"
capture="environment"
onChange={(e) => handlePhotoChange(e.target.files?.[0] ?? null)}
className="w-full border-2 border-ink bg-cream p-[8px] font-mono text-[11px]"
/>
<p className="mt-[4px] font-mono text-[9.5px] opacity-50">
{t('publicForm.photo.squareHint')}
</p>
<span className="font-mono text-[12px]">
{t('publicForm.field.willingToPayFee')}
<RequiredMark />
</span>
</label>
) : null}