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, `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 testée séparément des coordonnées PDF vu le nombre d'exceptions
orthographiques : accord de « quatre-vingts »/« cents », « et onze » orthographiques : accord de « quatre-vingts »/« cents », « et onze »
irrégulier à 71) et la date du versement sont complétés ; l'encadré irrégulier à 71) et la date du versement sont complétés. L'encadré
« Organisme bénéficiaire » (page 1) reste vierge, à la main, « Organisme bénéficiaire » (page 1), les cases articles 200/978 et la
volontairement — pas de données de l'association en base à ce jour. 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 `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 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 ; 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). place ; chaîne vide en écriture ⇒ l'efface).
- **`FormPaymentOption`** : un type de paiement de la cotisation (ex. - **`FormPaymentOption`** : un type de paiement de la cotisation (ex.
« Cotisation classique », « Mécénat ») — `label` + `amount` (montant « 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 édition, autant que la coordination veut (comme les créneaux types
`DayPart`), gérée depuis l'écran « Inscriptions » (bloc « Types de `DayPart`), gérée depuis l'écran « Inscriptions » (bloc « Types de
paiement »). CRUD `GET/POST/PATCH/DELETE /editions/{id}/form/payment-options` 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 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 « 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. 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 | | 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 | | 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 | | 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 **Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur
de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans + de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans +
+75 -2
View File
@@ -30,6 +30,7 @@ import json
import logging import logging
import secrets import secrets
import uuid import uuid
from decimal import Decimal, InvalidOperation
from fastapi import ( from fastapi import (
APIRouter, APIRouter,
@@ -81,6 +82,13 @@ from app.email import (
send, send,
) )
from app.forms import verify_hcaptcha 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 ( from app.models import (
Account, Account,
Artist, 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 # 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. # postes « au cas où » — force une vraie priorisation dès le formulaire.
_MAX_POSITION_WISHES = 2 _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 # 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`), # (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 # 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, amount=payload.amount,
order=payload.order, order=payload.order,
requires_mecenat_info=payload.requires_mecenat_info, requires_mecenat_info=payload.requires_mecenat_info,
free_amount=payload.free_amount,
) )
session.add(option) session.add(option)
try: try:
@@ -427,6 +439,7 @@ def get_public_form(
label=o.label, label=o.label,
amount=o.amount, amount=o.amount,
requires_mecenat_info=o.requires_mecenat_info, requires_mecenat_info=o.requires_mecenat_info,
free_amount=o.free_amount,
) )
for o in options for o in options
], ],
@@ -528,6 +541,31 @@ def _parse_id_list(raw: str | None) -> list[uuid.UUID]:
) from exc ) 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( def _check_one_artist_wish_per_day(
session: Session, edition_id: uuid.UUID, artist_ids: list[uuid.UUID] session: Session, edition_id: uuid.UUID, artist_ids: list[uuid.UUID]
) -> None: ) -> None:
@@ -565,6 +603,7 @@ async def submit_public_form(
companion_wish_note: str | None = Form(None), companion_wish_note: str | None = Form(None),
available: bool = Form(False), available: bool = Form(False),
payment_option_id: str | None = Form(None), payment_option_id: str | None = Form(None),
payment_amount: str | None = Form(None),
position_wish_ids: str | None = Form(None), position_wish_ids: str | None = Form(None),
artist_wish_ids: str | None = Form(None), artist_wish_ids: str | None = Form(None),
photo: UploadFile | None = File(None), photo: UploadFile | None = File(None),
@@ -643,6 +682,11 @@ async def submit_public_form(
raise HTTPException( raise HTTPException(
status_code=422, detail={"code": "form.unknown_payment_option"} 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 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 json.dumps([str(i) for i in artist_ids]) if artist_ids else None
), ),
payment_label=payment_option.label, payment_label=payment_option.label,
payment_amount=payment_option.amount, payment_amount=amount_to_pay,
requires_mecenat_info=payment_option.requires_mecenat_info, requires_mecenat_info=payment_option.requires_mecenat_info,
) )
if photo_data: if photo_data:
@@ -789,6 +833,8 @@ def _promote_pending(
) )
session.add(volunteer) session.add(volunteer)
session.flush() session.flush()
if volunteer.mecenat_address:
assign_receipt_number(session, volunteer)
position_ids = _filter_owned_ids( position_ids = _filter_owned_ids(
session, Position, edition.id, _parse_id_list(pending.position_wish_ids_json) session, Position, edition.id, _parse_id_list(pending.position_wish_ids_json)
@@ -822,21 +868,48 @@ def _promote_pending(
session.commit() session.commit()
if volunteer.email: 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( subject, body = render_registration_paid_confirmation(
first_name=volunteer.first_name, first_name=volunteer.first_name,
label=volunteer.fee_payment_label or "", label=volunteer.fee_payment_label or "",
amount=volunteer.fee_amount or 0, amount=volunteer.fee_amount or 0,
with_mecenat_receipt=receipt is not None,
) )
background.add_task( background.add_task(
send, _send_paid_confirmation,
volunteer.email, volunteer.email,
subject, subject,
body, body,
logo=edition_logo(session, edition.id), logo=edition_logo(session, edition.id),
receipt=receipt,
) )
return volunteer 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( def _apply_pending_checkout_result(
session: Session, session: Session,
edition: Edition, edition: Edition,
+5
View File
@@ -537,6 +537,7 @@ class FormPaymentOptionRead(ApiModel):
amount: float amount: float
order: int order: int
requires_mecenat_info: bool requires_mecenat_info: bool
free_amount: bool
created_at: dt.datetime created_at: dt.datetime
updated_at: dt.datetime updated_at: dt.datetime
@@ -546,6 +547,7 @@ class FormPaymentOptionCreate(ApiModel):
amount: float amount: float
order: int = 0 order: int = 0
requires_mecenat_info: bool = False requires_mecenat_info: bool = False
free_amount: bool = False
class FormPaymentOptionUpdate(ApiModel): class FormPaymentOptionUpdate(ApiModel):
@@ -553,6 +555,7 @@ class FormPaymentOptionUpdate(ApiModel):
amount: float | None = None amount: float | None = None
order: int | None = None order: int | None = None
requires_mecenat_info: bool | None = None requires_mecenat_info: bool | None = None
free_amount: bool | None = None
class PublicPositionOption(ApiModel): class PublicPositionOption(ApiModel):
@@ -572,8 +575,10 @@ class PublicPaymentOption(ApiModel):
id: uuid.UUID id: uuid.UUID
label: str label: str
# Montant fixe, ou minimum si `free_amount`.
amount: float amount: float
requires_mecenat_info: bool requires_mecenat_info: bool
free_amount: bool
class PublicFormConfig(ApiModel): class PublicFormConfig(ApiModel):
+10 -14
View File
@@ -30,7 +30,7 @@ from app.auth.deps import (
) )
from app.db import get_session from app.db import get_session
from app.exporting import generate_volunteers_xlsx 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 ( from app.models import (
Account, Account,
Artist, Artist,
@@ -247,19 +247,15 @@ def export_mecenat_receipts(
status_code=422, detail={"code": "mecenat.no_receipts"} status_code=422, detail={"code": "mecenat.no_receipts"}
) )
receipts = [ # Bénévoles payés avant la numérotation (ou jamais exportés) : numéro
MecenatReceipt( # attribué maintenant, dans l'ordre des paiements, puis figé.
last_name=v.last_name, unnumbered = [v for v in volunteers if not v.mecenat_receipt_number]
first_name=v.first_name, if unnumbered:
address=v.mecenat_address or "", for v in sorted(unnumbered, key=lambda v: v.fee_paid_at or v.created_at):
postal_code=v.mecenat_postal_code or "", assign_receipt_number(session, v)
city=v.mecenat_city or "", session.commit()
country=v.mecenat_country or "",
amount=v.fee_amount or 0.0, receipts = [receipt_for(v) for v in volunteers]
payment_date=(v.fee_paid_at or v.created_at).date(),
)
for v in volunteers
]
content = generate_mecenat_receipts_pdf(receipts) content = generate_mecenat_receipts_pdf(receipts)
return Response( return Response(
content=content, content=content,
+26 -5
View File
@@ -8,7 +8,7 @@ Pour diagnostiquer : `python -m app.email.check <destinataire>`."""
import logging import logging
import smtplib import smtplib
import ssl import ssl
from collections.abc import Iterable from collections.abc import Iterable, Sequence
from email.message import EmailMessage from email.message import EmailMessage
from app.config import Settings, get_settings from app.config import Settings, get_settings
@@ -33,8 +33,17 @@ def _smtp_error_summary(exc: smtplib.SMTPException) -> str:
return exc.__class__.__name__ return exc.__class__.__name__
# (octets, content-type, nom de fichier) d'une pièce jointe.
Attachment = tuple[bytes, str, str]
def build_message( 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: ) -> EmailMessage:
"""`logo` = (octets, content-type) (voir `app/email/branding.py: """`logo` = (octets, content-type) (voir `app/email/branding.py:
edition_logo`) : ajoute une version HTML au thème de l'appli 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 » 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 (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 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 = EmailMessage()
message["From"] = get_settings().smtp_from message["From"] = get_settings().smtp_from
message["To"] = to message["To"] = to
@@ -60,6 +71,9 @@ def build_message(
html_part.add_related( html_part.add_related(
content, maintype or "image", subtype or "png", cid=f"<{LOGO_CID}>" 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 return message
@@ -131,8 +145,15 @@ def deliver_many(messages: list[EmailMessage]) -> None:
logger.info("Emails remis : %d/%d", sent, len(messages)) logger.info("Emails remis : %d/%d", sent, len(messages))
def send(to: str, subject: str, body: str, *, logo: tuple[bytes, str] | None = None) -> None: def send(
deliver_many([build_message(to, subject, body, logo=logo)]) 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( def send_many(
+11 -10
View File
@@ -32,22 +32,23 @@ def render_registration_confirmation(*, first_name: str) -> tuple[str, str]:
def render_registration_paid_confirmation( 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]: ) -> tuple[str, str]:
"""Inscription prise en compte **et** paiement en ligne confirmé — envoyé """Inscription prise en compte **et** paiement en ligne confirmé — envoyé
une fois seulement, au moment de la promotion (`app/api/forms.py: une fois seulement, au moment de la promotion (`app/api/forms.py:
_promote_pending`). Sert de reçu (montant, type de paiement) en _promote_pending`). Sert de reçu (montant, type de paiement) en
l'absence de confirmation documentée que l'API SumUp Hosted Checkout en l'absence de confirmation documentée que l'API SumUp Hosted Checkout en
envoie un elle-même.""" envoie un elle-même. `with_mecenat_receipt` : le cerfa rempli est joint
return ( (`_promote_pending`), le corps l'annonce."""
t("email.registration_confirmation_paid.subject"), body = t(
t( "email.registration_confirmation_paid.body",
"email.registration_confirmation_paid.body", prenom=first_name,
prenom=first_name, label=label,
label=label, montant=amount,
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: 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.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.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.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_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.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.", "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_empty": "Le logo est vide.",
"form.logo_too_large": "Le logo est trop volumineux.", "form.logo_too_large": "Le logo est trop volumineux.",
"form.invalid_sumup_amount": "Le montant doit être supérieur à 0.", "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.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.duplicate_payment_option_label": "Un type de paiement porte déjà ce libellé.",
"form.pending_already_paid": "Cette inscription est déjà payée.", "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.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 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 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). 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 Les informations de l'organisme bénéficiaire (page 1), les cases articles
bénéficiaire ») ne sont volontairement pas renseignées : à compléter à la 200/978 et la signature (page 2) sont pré-remplies dans le modèle lui-même
main par la coordination avant remise au bénévole. par l'association — rien à dessiner ici.
""" """
import datetime as dt 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) _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 = _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) _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) @dataclass(frozen=True)
@@ -71,6 +75,7 @@ class MecenatReceipt:
country: str country: str
amount: float amount: float
payment_date: dt.date payment_date: dt.date
number: str = ""
def _fitted_font_size(text: str, max_width: float, font_size: float, bold: bool) -> float: 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) 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: def _overlay_page(receipt: MecenatReceipt) -> PageObject:
buffer = io.BytesIO() buffer = io.BytesIO()
c = canvas.Canvas(buffer, pagesize=(_PAGE_W, _PAGE_H)) 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, f"{receipt.amount:.2f}".replace(".", ","), _AMOUNT)
_draw(c, amount_in_words(receipt.amount), _AMOUNT_IN_WORDS) _draw(c, amount_in_words(receipt.amount), _AMOUNT_IN_WORDS)
_draw(c, receipt.payment_date.strftime("%d/%m/%Y"), _PAYMENT_DATE) _draw(c, receipt.payment_date.strftime("%d/%m/%Y"), _PAYMENT_DATE)
c.showPage() _draw(c, receipt.payment_date.strftime("%d/%m/%Y"), _CERFA_DATE)
c.save() return _finish(c, buffer)
buffer.seek(0)
return PdfReader(buffer).pages[0]
def generate_mecenat_receipts_pdf(receipts: list[MecenatReceipt]) -> bytes: 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 """Un reçu = les 2 pages du cerfa officiel, page 1 complétée avec le
informations du donateur, du montant et de la date — voir le docstring 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 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` 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 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() writer = PdfWriter()
for receipt in receipts: for receipt in receipts:
base = PdfReader(_TEMPLATE_PATH) 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 # `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 # 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). # 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_city: str | None = None
mecenat_postal_code: str | None = None mecenat_postal_code: str | None = None
mecenat_country: 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( wished_artists: list[Artist] = Relationship(
back_populates="fans", back_populates="fans",
@@ -571,6 +576,13 @@ class FormPaymentOption(TimestampMixin, table=True):
default=False, default=False,
sa_column=Column(Boolean, nullable=False, server_default="false"), sa_column=Column(Boolean, nullable=False, server_default="false"),
) )
# 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): 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 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 » """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 (voir `app/api/forms.py:_promote_pending`) — `mecenat_address` est le
marqueur qui distingue ce chemin d'un simple paiement classique.""" marqueur qui distingue ce chemin d'un simple paiement classique."""
volunteer.fee_payment_status = FeePaymentStatus.PAID volunteer.fee_payment_status = FeePaymentStatus.PAID
volunteer.fee_amount = amount 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_address = "12 rue des Lilas"
volunteer.mecenat_city = "Paris" volunteer.mecenat_city = "Paris"
volunteer.mecenat_postal_code = "75020" 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 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): def test_mecenat_receipts_is_422_without_any_paid_donor(client, edition):
resp = client.get(_url(edition, "/mecenat-receipts")) resp = client.get(_url(edition, "/mecenat-receipts"))
assert resp.status_code == 422 assert resp.status_code == 422
+172 -1
View File
@@ -7,9 +7,10 @@ import io
import uuid import uuid
import pytest import pytest
from pypdf import PdfReader
from app.auth.rate_limit import form_submission_limiter, pending_status_limiter 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 app.sumup import SumUpCheckout
from tests.factories import make_artist, make_position from tests.factories import make_artist, make_position
from tests.mail import plain_text 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" 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): 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.put(_form_url(edition, "/fields"), json={"fields": ["WILLING_TO_PAY_FEE"]})
client.patch( client.patch(
@@ -1002,3 +1088,88 @@ def test_pending_status_polling_is_rate_limited_per_token(
for _ in range(pending_status_limiter.max_attempts): for _ in range(pending_status_limiter.max_attempts):
pending_status_limiter.hit(token) pending_status_limiter.hit(token)
assert raw_client.get(_form_url(edition, f"/public/pending/{token}")).status_code == 429 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.companion_wish_note) form.append('companion_wish_note', body.companion_wish_note);
if (body.available !== undefined) form.append('available', String(body.available)); 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_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) { if (body.position_wish_ids) {
form.append('position_wish_ids', JSON.stringify(body.position_wish_ids)); form.append('position_wish_ids', JSON.stringify(body.position_wish_ids));
} }
+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 // Approche de `str.title()` (Python) utilisé côté back-end à la soumission
// du formulaire public — capitalise chaque mot (« - » compris). // 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 => const titleCase = (s: string): string =>
s.toLowerCase().replace(/\b\p{L}/gu, (c) => c.toUpperCase()); s.toLowerCase().replace(/\b\p{L}/gu, (c) => c.toUpperCase());
@@ -1647,6 +1657,7 @@ export function createMockApi(): PlanningApi {
label: o.label, label: o.label,
amount: o.amount, amount: o.amount,
requires_mecenat_info: o.requires_mecenat_info, requires_mecenat_info: o.requires_mecenat_info,
free_amount: o.free_amount,
})), })),
}); });
}, },
@@ -1666,6 +1677,7 @@ export function createMockApi(): PlanningApi {
amount: body.amount, amount: body.amount,
order: body.order ?? 0, order: body.order ?? 0,
requires_mecenat_info: body.requires_mecenat_info ?? false, requires_mecenat_info: body.requires_mecenat_info ?? false,
free_amount: body.free_amount ?? false,
created_at: now, created_at: now,
updated_at: now, updated_at: now,
}; };
@@ -1691,6 +1703,7 @@ export function createMockApi(): PlanningApi {
...(body.requires_mecenat_info !== undefined ...(body.requires_mecenat_info !== undefined
? { requires_mecenat_info: body.requires_mecenat_info } ? { requires_mecenat_info: body.requires_mecenat_info }
: {}), : {}),
...(body.free_amount !== undefined ? { free_amount: body.free_amount } : {}),
updated_at: touch(), updated_at: touch(),
}); });
return delay<FormPaymentOptionRead>(option); return delay<FormPaymentOptionRead>(option);
@@ -1802,7 +1815,9 @@ export function createMockApi(): PlanningApi {
artistIds, artistIds,
photo, photo,
paymentLabel: paymentOption.label, paymentLabel: paymentOption.label,
paymentAmount: paymentOption.amount, paymentAmount: paymentOption.free_amount
? freeAmount(body.payment_amount, paymentOption.amount)
: paymentOption.amount,
requiresMecenatInfo: paymentOption.requires_mecenat_info, requiresMecenatInfo: paymentOption.requires_mecenat_info,
mecenatAddress: null, mecenatAddress: null,
mecenatCity: 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 /** Type « mécénat » : le bénévole passe par une étape d'adresse postale
* avant le paiement (reçu fiscal / cerfa). */ * avant le paiement (reçu fiscal / cerfa). */
requires_mecenat_info: boolean; requires_mecenat_info: boolean;
/** Montant libre : le bénévole saisit son montant, `amount` = minimum. */
free_amount: boolean;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
@@ -627,6 +629,7 @@ export interface FormPaymentOptionCreate {
amount: number; amount: number;
order?: number; order?: number;
requires_mecenat_info?: boolean; requires_mecenat_info?: boolean;
free_amount?: boolean;
} }
export interface FormPaymentOptionUpdate { export interface FormPaymentOptionUpdate {
@@ -634,6 +637,7 @@ export interface FormPaymentOptionUpdate {
amount?: number; amount?: number;
order?: number; order?: number;
requires_mecenat_info?: boolean; requires_mecenat_info?: boolean;
free_amount?: boolean;
} }
export interface PublicPositionOption { export interface PublicPositionOption {
@@ -652,8 +656,10 @@ export interface PublicArtistOption {
export interface PublicPaymentOption { export interface PublicPaymentOption {
id: string; id: string;
label: string; label: string;
/** Montant fixe, ou minimum si `free_amount`. */
amount: number; amount: number;
requires_mecenat_info: boolean; requires_mecenat_info: boolean;
free_amount: boolean;
} }
/** Tout ce qu'il faut pour afficher le formulaire public — jamais plus. */ /** 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 /** Type de paiement choisi (voir `PublicPaymentOption`) — absent = « je
* paierai sur place » (aucun paiement en ligne). */ * paierai sur place » (aucun paiement en ligne). */
payment_option_id?: string; payment_option_id?: string;
/** Montant saisi, pour un type de paiement à montant libre seulement. */
payment_amount?: string;
position_wish_ids?: string[]; position_wish_ids?: string[];
artist_wish_ids?: string[]; artist_wish_ids?: string[];
photo?: File | null; 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 ( return (
<span className="mb-[5px] block font-display text-[9px] font-extrabold uppercase tracking-widest"> <span className="mb-[5px] block font-display text-[9px] font-extrabold uppercase tracking-widest">
{children} {children}
{required ? <RequiredMark /> : null}
</span> </span>
); );
} }
+8 -1
View File
@@ -390,7 +390,7 @@
"config.vol.feeStatus.failed": "Cotisation : paiement échoué", "config.vol.feeStatus.failed": "Cotisation : paiement échoué",
"config.vol.feeStatus.expired": "Cotisation : lien de paiement expiré", "config.vol.feeStatus.expired": "Cotisation : lien de paiement expiré",
"config.vol.mecenatAddress": "Adresse (cerfa)", "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.field.comment": "Commentaire",
"form.link.title": "Lien du formulaire", "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.", "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.label": "Libellé (ex. Cotisation classique, Mécénat…)",
"form.customize.paymentOptions.add": "Ajouter", "form.customize.paymentOptions.add": "Ajouter",
"form.customize.paymentOptions.mecenat": "Mécénat", "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).", "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", "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.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.willingToPayFee": "Je participe à la cotisation bénévole",
"publicForm.field.paymentOption": "Cotisation", "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.paymentOption.onSite": "Je paierai sur place",
"publicForm.photo.squareHint": "Recadrée automatiquement en carré.", "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.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.captcha.disabled": "(Vérification anti-robot non configurée sur cette instance.)",
"publicForm.submitting": "Envoi…", "publicForm.submitting": "Envoi…",
"publicForm.requiredLegend": "champ obligatoire",
"publicForm.submit": "Envoyer mon inscription", "publicForm.submit": "Envoyer mon inscription",
"publicForm.success.title": "Inscription reçue", "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.", "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_empty": "Le logo est vide.",
"form.logo_too_large": "Le logo est trop volumineux.", "form.logo_too_large": "Le logo est trop volumineux.",
"form.invalid_sumup_amount": "Le montant doit être supérieur à 0.", "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.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.duplicate_payment_option_label": "Un type de paiement porte déjà ce libellé.",
"form.pending_already_paid": "Cette inscription est déjà payée.", "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 [label, setLabel] = useState('');
const [amount, setAmount] = useState(''); const [amount, setAmount] = useState('');
const [requiresMecenatInfo, setRequiresMecenatInfo] = useState(false); 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.isLoading) return <div className="font-mono text-[10px] opacity-60">…</div>;
if (options.error) return <ErrorNote error={options.error} />; 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]" 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> <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 <label
className="flex flex-none cursor-pointer items-center gap-1 font-mono text-[9.5px] opacity-70" className="flex flex-none cursor-pointer items-center gap-1 font-mono text-[9.5px] opacity-70"
title={t('form.customize.paymentOptions.mecenat.help')} 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]" 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> <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 <label
className="flex flex-none cursor-pointer items-center gap-1 font-mono text-[9.5px] opacity-70" className="flex flex-none cursor-pointer items-center gap-1 font-mono text-[9.5px] opacity-70"
title={t('form.customize.paymentOptions.mecenat.help')} title={t('form.customize.paymentOptions.mecenat.help')}
@@ -450,12 +475,14 @@ function PaymentOptionsForm() {
amount: Number(amount), amount: Number(amount),
order: rows.length, order: rows.length,
requires_mecenat_info: requiresMecenatInfo, requires_mecenat_info: requiresMecenatInfo,
free_amount: freeAmount,
}, },
{ {
onSuccess: () => { onSuccess: () => {
setLabel(''); setLabel('');
setAmount(''); setAmount('');
setRequiresMecenatInfo(false); setRequiresMecenatInfo(false);
setFreeAmount(false);
}, },
}, },
) )
+88 -38
View File
@@ -12,7 +12,7 @@ import {
} from '@/api/hooks'; } from '@/api/hooks';
import type { PublicArtistOption } from '@/api/schema'; import type { PublicArtistOption } from '@/api/schema';
import { ErrorNote, Loading } from '@/components/state'; 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 { t } from '@/i18n';
import { ENV } from '@/lib/env'; import { ENV } from '@/lib/env';
import { longDayLabel } from '@/lib/time'; 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 { function errorMessage(error: unknown): string {
if (error instanceof ApiError) return t(error.code); if (error instanceof ApiError) return t(error.code);
return t('error.generic'); return t('error.generic');
@@ -271,21 +282,21 @@ function MecenatInfoStep({
{t('publicForm.mecenat.hint')} {t('publicForm.mecenat.hint')}
</p> </p>
<label className="block"> <label className="block">
<FieldLabel>{t('publicForm.mecenat.address')}</FieldLabel> <FieldLabel required>{t('publicForm.mecenat.address')}</FieldLabel>
<TextInput value={address} onChange={setAddress} /> <TextInput value={address} onChange={setAddress} />
</label> </label>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<label className="block"> <label className="block">
<FieldLabel>{t('publicForm.mecenat.postalCode')}</FieldLabel> <FieldLabel required>{t('publicForm.mecenat.postalCode')}</FieldLabel>
<TextInput mono value={postalCode} onChange={setPostalCode} /> <TextInput mono value={postalCode} onChange={setPostalCode} />
</label> </label>
<label className="block"> <label className="block">
<FieldLabel>{t('publicForm.mecenat.city')}</FieldLabel> <FieldLabel required>{t('publicForm.mecenat.city')}</FieldLabel>
<TextInput value={city} onChange={setCity} /> <TextInput value={city} onChange={setCity} />
</label> </label>
</div> </div>
<label className="block"> <label className="block">
<FieldLabel>{t('publicForm.mecenat.country')}</FieldLabel> <FieldLabel required>{t('publicForm.mecenat.country')}</FieldLabel>
<TextInput value={country} onChange={setCountry} /> <TextInput value={country} onChange={setCountry} />
</label> </label>
{error ? ( {error ? (
@@ -354,6 +365,7 @@ function PublicSignupForm({ accessToken }: { accessToken: string }) {
const [available, setAvailable] = useState(false); const [available, setAvailable] = useState(false);
const [willingToPayFee, setWillingToPayFee] = useState(false); const [willingToPayFee, setWillingToPayFee] = useState(false);
const [paymentOptionId, setPaymentOptionId] = useState(''); const [paymentOptionId, setPaymentOptionId] = useState('');
const [freeAmount, setFreeAmount] = useState('');
const [positionIds, setPositionIds] = useState<string[]>([]); const [positionIds, setPositionIds] = useState<string[]>([]);
const [artistByDay, setArtistByDay] = useState<Record<string, string>>({}); const [artistByDay, setArtistByDay] = useState<Record<string, string>>({});
const [photo, setPhoto] = useState<File | null>(null); const [photo, setPhoto] = useState<File | null>(null);
@@ -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 // Tout champ activé par la coordination est obligatoire — le formulaire
// public n'est plus une simple collecte d'infos facultatives. // public n'est plus une simple collecte d'infos facultatives.
const canSubmit = const canSubmit =
@@ -464,6 +480,7 @@ function PublicSignupForm({ accessToken }: { accessToken: string }) {
(!fields.has('PHONE') || phone.trim().length > 0) && (!fields.has('PHONE') || phone.trim().length > 0) &&
(!fields.has('AVAILABLE') || available) && (!fields.has('AVAILABLE') || available) &&
(!fields.has('WILLING_TO_PAY_FEE') || config.data.sumup_enabled || willingToPayFee) && (!fields.has('WILLING_TO_PAY_FEE') || config.data.sumup_enabled || willingToPayFee) &&
(!selectedOption?.free_amount || isValidFreeAmount(freeAmount, selectedOption.amount)) &&
(toggleEnabled ? captchaToken.length > 0 : true) && (toggleEnabled ? captchaToken.length > 0 : true) &&
!submit.isPending; !submit.isPending;
@@ -484,6 +501,7 @@ function PublicSignupForm({ accessToken }: { accessToken: string }) {
fields.has('WILLING_TO_PAY_FEE') && config.data.sumup_enabled fields.has('WILLING_TO_PAY_FEE') && config.data.sumup_enabled
? paymentOptionId || undefined ? paymentOptionId || undefined
: undefined, : undefined,
payment_amount: selectedOption?.free_amount ? freeAmount : undefined,
position_wish_ids: fields.has('POSITION_WISHES') ? positionIds : undefined, position_wish_ids: fields.has('POSITION_WISHES') ? positionIds : undefined,
artist_wish_ids: fields.has('ARTIST_WISHES') ? Object.values(artistByDay) : undefined, artist_wish_ids: fields.has('ARTIST_WISHES') ? Object.values(artistByDay) : undefined,
photo: fields.has('PHOTO') ? photo : undefined, photo: fields.has('PHOTO') ? photo : undefined,
@@ -516,27 +534,31 @@ function PublicSignupForm({ accessToken }: { accessToken: string }) {
</p> </p>
) : null} ) : null}
<form onSubmit={handleSubmit} className="flex flex-col gap-4 p-4"> <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"> <div className="grid grid-cols-2 gap-3">
<label className="block"> <label className="block">
<FieldLabel>{t('config.vol.field.firstName')}</FieldLabel> <FieldLabel required>{t('config.vol.field.firstName')}</FieldLabel>
<TextInput value={firstName} onChange={setFirstName} /> <TextInput value={firstName} onChange={setFirstName} />
</label> </label>
<label className="block"> <label className="block">
<FieldLabel>{t('config.vol.field.lastName')}</FieldLabel> <FieldLabel required>{t('config.vol.field.lastName')}</FieldLabel>
<TextInput value={lastName} onChange={setLastName} /> <TextInput value={lastName} onChange={setLastName} />
</label> </label>
</div> </div>
{fields.has('EMAIL') ? ( {fields.has('EMAIL') ? (
<label className="block"> <label className="block">
<FieldLabel>{t('form.field.email')}</FieldLabel> <FieldLabel required>{t('form.field.email')}</FieldLabel>
<TextInput mono value={email} onChange={setEmail} /> <TextInput mono value={email} onChange={setEmail} />
</label> </label>
) : null} ) : null}
{fields.has('PHONE') ? ( {fields.has('PHONE') ? (
<label className="block"> <label className="block">
<FieldLabel>{t('form.field.phone')}</FieldLabel> <FieldLabel required>{t('form.field.phone')}</FieldLabel>
<TextInput mono value={phone} onChange={setPhone} /> <TextInput mono value={phone} onChange={setPhone} />
</label> </label>
) : null} ) : null}
@@ -607,6 +629,26 @@ function PublicSignupForm({ accessToken }: { accessToken: string }) {
</label> </label>
) : null} ) : 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') ? ( {fields.has('COMMENT') ? (
<label className="block"> <label className="block">
<FieldLabel>{t('form.field.comment')}</FieldLabel> <FieldLabel>{t('form.field.comment')}</FieldLabel>
@@ -627,7 +669,10 @@ function PublicSignupForm({ accessToken }: { accessToken: string }) {
onChange={(e) => setAvailable(e.target.checked)} onChange={(e) => setAvailable(e.target.checked)}
className="h-[16px] w-[16px] accent-ink" 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> </label>
) : null} ) : null}
@@ -657,14 +702,39 @@ function PublicSignupForm({ accessToken }: { accessToken: string }) {
onChange={() => setPaymentOptionId(o.id)} onChange={() => setPaymentOptionId(o.id)}
className="h-[15px] w-[15px] accent-ink" 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> </label>
))} ))}
</div> </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"> <p className="mt-[6px] font-mono text-[10.5px] opacity-70">
{config.data.payment_options.find((o) => o.id === paymentOptionId) {selectedOption.requires_mecenat_info
?.requires_mecenat_info
? t('publicForm.mecenat.afterSubmitHint') ? t('publicForm.mecenat.afterSubmitHint')
: t('publicForm.sumup.afterSubmitHint')} : t('publicForm.sumup.afterSubmitHint')}
</p> </p>
@@ -678,30 +748,10 @@ function PublicSignupForm({ accessToken }: { accessToken: string }) {
onChange={(e) => setWillingToPayFee(e.target.checked)} onChange={(e) => setWillingToPayFee(e.target.checked)}
className="h-[16px] w-[16px] accent-ink" className="h-[16px] w-[16px] accent-ink"
/> />
<span className="font-mono text-[12px]">{t('publicForm.field.willingToPayFee')}</span> <span className="font-mono text-[12px]">
</label> {t('publicForm.field.willingToPayFee')}
) : null} <RequiredMark />
</span>
{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>
</label> </label>
) : null} ) : null}