Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c49ce520e3 | ||
|
|
ccd769d04e | ||
|
|
5a0209ce43 | ||
|
|
5675e1d220 |
@@ -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 +
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
"email.registration_confirmation_paid.body",
|
||||
prenom=first_name,
|
||||
label=label,
|
||||
montant=amount,
|
||||
),
|
||||
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:
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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
@@ -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).
|
||||
|
||||
@@ -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")
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user