From c49ce520e3003f6fbc7c95ba5bbdba37a420f6a5 Mon Sep 17 00:00:00 2001 From: AdamThomas Date: Sat, 3 Oct 2026 13:49:27 +0200 Subject: [PATCH] =?UTF-8?q?Type=20de=20paiement=20=C3=A0=20montant=20libre?= =?UTF-8?q?=20(pens=C3=A9=20pour=20le=20m=C3=A9c=C3=A9nat)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CLAUDE.md | 9 +- backend/app/api/forms.py | 39 ++++++++- backend/app/api/schemas.py | 5 ++ backend/app/i18n/locales/fr.json | 1 + .../0021_payment_option_free_amount.py | 32 +++++++ backend/app/models/domain.py | 7 ++ backend/tests/test_forms_api.py | 84 +++++++++++++++++++ frontend/src/api/http-client.ts | 1 + frontend/src/api/mock/mock-client.ts | 17 +++- frontend/src/api/schema.ts | 8 ++ frontend/src/i18n/locales/fr.json | 6 ++ frontend/src/pages/FormBuilderPage.tsx | 27 ++++++ frontend/src/pages/PublicSignupPage.tsx | 51 ++++++++++- 13 files changed, 280 insertions(+), 7 deletions(-) create mode 100644 backend/app/migrations/versions/0021_payment_option_free_amount.py diff --git a/CLAUDE.md b/CLAUDE.md index f65244a..b9bfec3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -255,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` @@ -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 | | 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 + diff --git a/backend/app/api/forms.py b/backend/app/api/forms.py index 8f9e8a7..8e52a66 100644 --- a/backend/app/api/forms.py +++ b/backend/app/api/forms.py @@ -30,6 +30,7 @@ import json import logging import secrets import uuid +from decimal import Decimal, InvalidOperation from fastapi import ( 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 # 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 @@ -275,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: @@ -434,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 ], @@ -535,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: @@ -572,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), @@ -650,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 @@ -713,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: diff --git a/backend/app/api/schemas.py b/backend/app/api/schemas.py index a42018e..6936f86 100644 --- a/backend/app/api/schemas.py +++ b/backend/app/api/schemas.py @@ -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): diff --git a/backend/app/i18n/locales/fr.json b/backend/app/i18n/locales/fr.json index 7266a9b..5f62918 100644 --- a/backend/app/i18n/locales/fr.json +++ b/backend/app/i18n/locales/fr.json @@ -88,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.", diff --git a/backend/app/migrations/versions/0021_payment_option_free_amount.py b/backend/app/migrations/versions/0021_payment_option_free_amount.py new file mode 100644 index 0000000..92334b5 --- /dev/null +++ b/backend/app/migrations/versions/0021_payment_option_free_amount.py @@ -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") diff --git a/backend/app/models/domain.py b/backend/app/models/domain.py index b6b2bec..20ce39b 100644 --- a/backend/app/models/domain.py +++ b/backend/app/models/domain.py @@ -576,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): diff --git a/backend/tests/test_forms_api.py b/backend/tests/test_forms_api.py index d2620dc..72348b5 100644 --- a/backend/tests/test_forms_api.py +++ b/backend/tests/test_forms_api.py @@ -1089,3 +1089,87 @@ def test_pending_status_polling_is_rate_limited_per_token( 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 diff --git a/frontend/src/api/http-client.ts b/frontend/src/api/http-client.ts index ae1d962..7368df3 100644 --- a/frontend/src/api/http-client.ts +++ b/frontend/src/api/http-client.ts @@ -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)); } diff --git a/frontend/src/api/mock/mock-client.ts b/frontend/src/api/mock/mock-client.ts index 5f19569..5daa99b 100644 --- a/frontend/src/api/mock/mock-client.ts +++ b/frontend/src/api/mock/mock-client.ts @@ -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(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, diff --git a/frontend/src/api/schema.ts b/frontend/src/api/schema.ts index 7c63896..2b369dc 100644 --- a/frontend/src/api/schema.ts +++ b/frontend/src/api/schema.ts @@ -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; diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json index 5cdc316..58b5163 100644 --- a/frontend/src/i18n/locales/fr.json +++ b/frontend/src/i18n/locales/fr.json @@ -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,6 +433,9 @@ "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.", @@ -470,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.", diff --git a/frontend/src/pages/FormBuilderPage.tsx b/frontend/src/pages/FormBuilderPage.tsx index f8e9011..774fbee 100644 --- a/frontend/src/pages/FormBuilderPage.tsx +++ b/frontend/src/pages/FormBuilderPage.tsx @@ -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
…
; if (options.error) return ; @@ -386,6 +387,18 @@ function PaymentOptionsForm() { className="w-[90px] border-2 border-ink bg-paper px-1 py-1 font-mono text-[11px]" /> € +