Formulaire public : paiement de la cotisation via l'API SumUp (remplace Yapla)
ci-cd / build-backend (push) Failing after 4m12s
ci-cd / build-frontend (push) Skipped
ci-cd / deploy (push) Skipped

Remplace le simple lien Yapla statique par une vraie intégration API SumUp :
checkout hébergé créé à la soumission du formulaire, statut de paiement
(PENDING/PAID/FAILED/EXPIRED) tenu à jour par un webhook SumUp et visible sur
la fiche du bénévole. La cotisation n'est plus un montant unique : la
coordination configure autant de types de paiement qu'elle veut (libellé +
montant, ex. « Cotisation classique », « Mécénat »), et le bénévole en
choisit un à l'inscription — le libellé et le montant choisis sont figés sur
le bénévole, indépendamment d'une modification ultérieure de la config.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
AdamThomas
2026-09-29 19:56:04 +02:00
co-authored by Claude Sonnet 5
parent 0be5daa89c
commit 0bd38a6465
25 changed files with 1457 additions and 125 deletions
+242 -11
View File
@@ -7,8 +7,26 @@ import io
from app.auth.rate_limit import form_submission_limiter
from app.models import Role
from app.sumup import SumUpCheckout
from tests.factories import make_artist, make_position
_MERCHANT = "MXXXXX"
_API_KEY = "sup_sk_secret"
def _configure_sumup(client, edition, amount=12.5, label="Cotisation classique"):
"""Active le champ, renseigne les identifiants et crée un type de
paiement — renvoie son id, à passer en `payment_option_id` à `_submit`."""
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": label, "amount": amount}
)
return resp.json()["id"]
# PNG 1×1 valide (transparent), le plus petit possible.
_PNG = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY"
@@ -74,26 +92,130 @@ def test_clearing_description_with_an_explicit_null(client, edition):
assert resp.json()["description"] is None
def test_no_yapla_link_by_default(client, raw_client, edition):
assert client.get(_form_url(edition)).json()["yapla_url"] is None
assert raw_client.get(_form_url(edition, "/public")).json()["yapla_url"] is None
def test_sumup_not_configured_by_default(client, raw_client, edition):
body = client.get(_form_url(edition)).json()
assert body["sumup_merchant_code"] is None
assert body["sumup_api_key_set"] is False
public = raw_client.get(_form_url(edition, "/public")).json()
assert public["sumup_enabled"] is False
assert public["payment_options"] == []
def test_setting_the_yapla_link(client, raw_client, edition):
def test_setting_sumup_credentials_never_echoes_the_api_key(client, raw_client, edition):
resp = client.patch(
_form_url(edition), json={"yapla_url": "https://yapla.com/exemple/cotisation"}
_form_url(edition),
json={"sumup_merchant_code": "MXXXXX", "sumup_api_key": "sup_sk_secret"},
)
assert resp.status_code == 200
assert resp.json()["yapla_url"] == "https://yapla.com/exemple/cotisation"
body = resp.json()
assert body["sumup_merchant_code"] == "MXXXXX"
assert body["sumup_api_key_set"] is True
assert "sumup_api_key" not in body
body = raw_client.get(_form_url(edition, "/public")).json()
assert body["yapla_url"] == "https://yapla.com/exemple/cotisation"
# Identifiants seuls, sans aucun type de paiement : pas encore activable.
client.put(_form_url(edition, "/fields"), json={"fields": ["WILLING_TO_PAY_FEE"]})
assert raw_client.get(_form_url(edition, "/public")).json()["sumup_enabled"] is False
client.post(_form_url(edition, "/payment-options"), json={"label": "Cotisation", "amount": 20})
assert raw_client.get(_form_url(edition, "/public")).json()["sumup_enabled"] is True
def test_rejects_a_yapla_link_without_a_scheme(client, edition):
resp = client.patch(_form_url(edition), json={"yapla_url": "yapla.com/exemple"})
def test_payment_options_start_empty(client, edition):
assert client.get(_form_url(edition, "/payment-options")).json() == []
def test_creating_and_listing_payment_options(client, raw_client, edition):
client.post(
_form_url(edition, "/payment-options"),
json={"label": "Mécénat", "amount": 50, "order": 1},
)
resp = client.post(
_form_url(edition, "/payment-options"),
json={"label": "Cotisation classique", "amount": 20, "order": 0},
)
assert resp.status_code == 201
body = resp.json()
assert body["label"] == "Cotisation classique"
assert body["amount"] == 20
listed = client.get(_form_url(edition, "/payment-options")).json()
assert [o["label"] for o in listed] == ["Cotisation classique", "Mécénat"]
client.put(_form_url(edition, "/fields"), json={"fields": ["WILLING_TO_PAY_FEE"]})
public_options = raw_client.get(_form_url(edition, "/public")).json()["payment_options"]
assert [o["label"] for o in public_options] == ["Cotisation classique", "Mécénat"]
assert [o["amount"] for o in public_options] == [20, 50]
def test_updating_and_deleting_a_payment_option(client, edition):
option_id = client.post(
_form_url(edition, "/payment-options"), json={"label": "Cotisation", "amount": 20}
).json()["id"]
resp = client.patch(
_form_url(edition, f"/payment-options/{option_id}"), json={"amount": 25}
)
assert resp.status_code == 200
assert resp.json()["amount"] == 25
resp = client.delete(_form_url(edition, f"/payment-options/{option_id}"))
assert resp.status_code == 204
assert client.get(_form_url(edition, "/payment-options")).json() == []
def test_payment_options_are_coordinator_only(edition, make_account, as_account):
lead = make_account(Role.LEAD, email="lead@e.fr")
lead_client = as_account(lead)
resp = lead_client.post(
_form_url(edition, "/payment-options"), json={"label": "Cotisation", "amount": 20}
)
assert resp.status_code == 403
def test_rejects_a_non_positive_payment_option_amount(client, edition):
resp = client.post(
_form_url(edition, "/payment-options"), json={"label": "Cotisation", "amount": 0}
)
assert resp.status_code == 422
assert resp.json()["detail"]["code"] == "form.invalid_yapla_url"
assert resp.json()["detail"]["code"] == "form.invalid_sumup_amount"
def test_rejects_a_duplicate_payment_option_label(client, edition):
client.post(_form_url(edition, "/payment-options"), json={"label": "Cotisation", "amount": 20})
resp = client.post(
_form_url(edition, "/payment-options"), json={"label": "Cotisation", "amount": 30}
)
assert resp.status_code == 409
assert resp.json()["detail"]["code"] == "form.duplicate_payment_option_label"
def test_deleting_a_payment_option_keeps_the_volunteers_snapshot(
raw_client, client, edition, monkeypatch
):
option_id = _configure_sumup(client, edition)
monkeypatch.setattr(
"app.api.forms.create_checkout",
lambda **kw: SumUpCheckout(id="co_1", status="PENDING", hosted_checkout_url="https://x"),
)
resp = _submit(
raw_client, edition,
first_name="Alex", last_name="Martin", hcaptcha_token="x",
payment_option_id=option_id,
)
volunteer_id = resp.json()["volunteer_id"]
client.delete(_form_url(edition, f"/payment-options/{option_id}"))
fiche = client.get(f"/editions/{edition.id}/volunteers/{volunteer_id}").json()
assert fiche["fee_payment_label"] == "Cotisation classique"
assert fiche["fee_amount"] == 12.5
def test_clearing_the_sumup_api_key(client, edition):
client.patch(_form_url(edition), json={"sumup_api_key": "sup_sk_secret"})
assert client.get(_form_url(edition)).json()["sumup_api_key_set"] is True
resp = client.patch(_form_url(edition), json={"sumup_api_key": ""})
assert resp.json()["sumup_api_key_set"] is False
def test_logo_is_coordinator_only(edition, make_account, as_account):
@@ -360,6 +482,115 @@ def test_submitting_with_a_rejected_captcha(raw_client, edition, monkeypatch):
assert resp.json()["detail"]["code"] == "form.captcha_failed"
def test_submitting_creates_a_sumup_checkout_when_a_payment_option_is_chosen(
raw_client, client, edition, monkeypatch
):
option_id = _configure_sumup(client, edition)
monkeypatch.setattr(
"app.api.forms.create_checkout",
lambda **kw: SumUpCheckout(
id="co_123", status="PENDING", hosted_checkout_url="https://checkout.sumup.com/pay/co_123"
),
)
resp = _submit(
raw_client, edition,
first_name="Alex", last_name="Martin", hcaptcha_token="x",
payment_option_id=option_id,
)
assert resp.status_code == 201
body = resp.json()
assert body["sumup_checkout_url"] == "https://checkout.sumup.com/pay/co_123"
fiche = client.get(f"/editions/{edition.id}/volunteers/{body['volunteer_id']}").json()
assert fiche["fee_payment_status"] == "PENDING"
assert fiche["fee_payment_label"] == "Cotisation classique"
assert fiche["fee_amount"] == 12.5
assert fiche["willing_to_pay_fee"] is True
def test_submitting_without_choosing_an_option_does_not_create_a_checkout(
raw_client, client, edition, monkeypatch
):
_configure_sumup(client, edition)
called = []
monkeypatch.setattr("app.api.forms.create_checkout", lambda **kw: called.append(kw))
resp = _submit(
raw_client, edition, first_name="Alex", last_name="Martin", hcaptcha_token="x"
)
assert resp.status_code == 201
assert resp.json()["sumup_checkout_url"] is None
assert called == []
def test_submitting_with_an_unknown_payment_option_is_rejected(raw_client, client, edition):
_configure_sumup(client, edition)
resp = _submit(
raw_client, edition,
first_name="Alex", last_name="Martin", hcaptcha_token="x",
payment_option_id="00000000-0000-0000-0000-000000000000",
)
assert resp.status_code == 422
assert resp.json()["detail"]["code"] == "form.unknown_payment_option"
def test_a_failed_checkout_call_does_not_block_registration(
raw_client, client, edition, monkeypatch
):
option_id = _configure_sumup(client, edition)
monkeypatch.setattr("app.api.forms.create_checkout", lambda **kw: None)
resp = _submit(
raw_client, edition,
first_name="Alex", last_name="Martin", hcaptcha_token="x",
payment_option_id=option_id,
)
assert resp.status_code == 201
body = resp.json()
assert body["sumup_checkout_url"] is None
fiche = client.get(f"/editions/{edition.id}/volunteers/{body['volunteer_id']}").json()
assert fiche["fee_payment_status"] is None
def test_sumup_webhook_updates_the_payment_status(raw_client, client, edition, monkeypatch):
option_id = _configure_sumup(client, edition)
monkeypatch.setattr(
"app.api.forms.create_checkout",
lambda **kw: SumUpCheckout(id="co_123", status="PENDING", hosted_checkout_url="https://x"),
)
resp = _submit(
raw_client, edition,
first_name="Alex", last_name="Martin", hcaptcha_token="x",
payment_option_id=option_id,
)
volunteer_id = resp.json()["volunteer_id"]
monkeypatch.setattr(
"app.api.forms.get_checkout",
lambda **kw: SumUpCheckout(id="co_123", status="PAID", hosted_checkout_url=None),
)
webhook_resp = raw_client.post(
_form_url(edition, "/public/sumup-webhook"),
json={"event_type": "CHECKOUT_STATUS_CHANGED", "id": "co_123"},
)
assert webhook_resp.status_code == 200
fiche = client.get(f"/editions/{edition.id}/volunteers/{volunteer_id}").json()
assert fiche["fee_payment_status"] == "PAID"
assert fiche["fee_paid_at"] is not None
def test_sumup_webhook_ignores_an_unknown_checkout(raw_client, client, edition, monkeypatch):
_configure_sumup(client, edition)
monkeypatch.setattr(
"app.api.forms.get_checkout",
lambda **kw: SumUpCheckout(id="co_unknown", status="PAID", hosted_checkout_url=None),
)
resp = raw_client.post(
_form_url(edition, "/public/sumup-webhook"),
json={"event_type": "CHECKOUT_STATUS_CHANGED", "id": "co_unknown"},
)
assert resp.status_code == 200
def test_submitting_is_rate_limited_per_edition(raw_client, edition):
for _ in range(form_submission_limiter.max_attempts):
form_submission_limiter.hit(str(edition.id))