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]" /> € +