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
+38 -1
View File
@@ -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: