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>
This commit is contained in:
AdamThomas
2026-10-03 13:49:27 +02:00
co-authored by Claude Opus 5.5
parent ccd769d04e
commit c49ce520e3
13 changed files with 280 additions and 7 deletions
+8 -1
View File
@@ -255,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`
@@ -843,6 +849,7 @@ Aucune dépendance locale requise hors Docker.
| 32 | Formulaire public : astérisque rouge sur les champs obligatoires (`RequiredMark`, `FieldLabel required`) et légende « * champ obligatoire » | à 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 | | 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 | | 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 +
+38 -1
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,
@@ -113,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
@@ -275,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:
@@ -434,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
], ],
@@ -535,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:
@@ -572,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),
@@ -650,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
@@ -713,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:
+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):
+1
View File
@@ -88,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.",
@@ -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")
+7
View File
@@ -576,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):
+84
View File
@@ -1089,3 +1089,87 @@ def test_pending_status_polling_is_rate_limited_per_token(
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;
+6
View File
@@ -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,6 +433,9 @@
"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.",
@@ -470,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);
}, },
}, },
) )
+47 -4
View File
@@ -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');
@@ -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,
@@ -684,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>