Type de paiement à montant libre (pensé pour le mécénat)
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:
co-authored by
Claude Opus 5.5
parent
ccd769d04e
commit
c49ce520e3
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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")
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user