Numéro d'ordre unique sur chaque reçu fiscal mécénat

Volunteer.mecenat_receipt_number (migration 0020, unique) : année du
paiement + rang sur 5 chiffres (202600001…), une seule numérotation pour
toutes les éditions, repartant à 1 chaque année. Attribué à la
confirmation du paiement, ou au premier export pour les bénévoles payés
avant (dans l'ordre des paiements), puis jamais réattribué — un reçu
regénéré garde son numéro. Attribution sérialisée par un verrou
pg_advisory_xact_lock (webhook et sondage peuvent confirmer deux
paiements à la fois). Imprimé centré dans la case « Numéro d'ordre du
reçu » en page 1 du cerfa.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
AdamThomas
2026-10-03 13:48:05 +02:00
co-authored by Claude Opus 5.5
parent 5a0209ce43
commit ccd769d04e
10 changed files with 214 additions and 16 deletions
+58 -2
View File
@@ -454,13 +454,13 @@ def test_export_volunteers_is_coordinator_only(edition, make_account, as_account
assert resp.status_code == 403
def _mark_mecenat_paid(session, volunteer, *, amount=50.0):
def _mark_mecenat_paid(session, volunteer, *, amount=50.0, paid_at=dt.datetime(2026, 3, 5, 10, 0)):
"""Simule l'état d'un bénévole ayant payé un type de paiement « mécénat »
(voir `app/api/forms.py:_promote_pending`) — `mecenat_address` est le
marqueur qui distingue ce chemin d'un simple paiement classique."""
volunteer.fee_payment_status = FeePaymentStatus.PAID
volunteer.fee_amount = amount
volunteer.fee_paid_at = dt.datetime(2026, 3, 5, 10, 0)
volunteer.fee_paid_at = paid_at
volunteer.mecenat_address = "12 rue des Lilas"
volunteer.mecenat_city = "Paris"
volunteer.mecenat_postal_code = "75020"
@@ -512,6 +512,62 @@ def test_mecenat_receipts_pdf_includes_only_paid_mecenat_donors(
assert "Petit" not in donor_page_text
def test_mecenat_receipts_are_numbered_by_payment_year_and_order(client, edition, session):
later = make_volunteer(session, edition, first_name="Alex", last_name="Martin")
_mark_mecenat_paid(session, later, paid_at=dt.datetime(2026, 4, 1, 9, 0))
earlier = make_volunteer(session, edition, first_name="Sam", last_name="Petit")
_mark_mecenat_paid(session, earlier, paid_at=dt.datetime(2026, 3, 1, 9, 0))
resp = client.get(_url(edition, "/mecenat-receipts"))
assert resp.status_code == 200
session.refresh(later)
session.refresh(earlier)
# Attribués dans l'ordre des paiements, pas de l'ordre alphabétique.
assert earlier.mecenat_receipt_number == "202600001"
assert later.mecenat_receipt_number == "202600002"
reader = PdfReader(io.BytesIO(resp.content))
# Trié par nom : Martin (pages 1-2) puis Petit (pages 3-4).
assert "202600002" in reader.pages[0].extract_text()
assert "202600001" in reader.pages[2].extract_text()
def test_mecenat_receipt_number_is_stable_across_regenerations(client, edition, session):
volunteer = make_volunteer(session, edition, first_name="Alex", last_name="Martin")
_mark_mecenat_paid(session, volunteer)
client.get(_url(edition, "/mecenat-receipts"))
session.refresh(volunteer)
first = volunteer.mecenat_receipt_number
newcomer = make_volunteer(session, edition, first_name="Sam", last_name="Petit")
_mark_mecenat_paid(session, newcomer)
client.get(_url(edition, "/mecenat-receipts"))
session.refresh(volunteer)
session.refresh(newcomer)
assert volunteer.mecenat_receipt_number == first
assert newcomer.mecenat_receipt_number == "202600002"
def test_mecenat_receipt_numbers_continue_the_existing_sequence(client, edition, session):
numbered = make_volunteer(session, edition, first_name="Alex", last_name="Martin")
_mark_mecenat_paid(session, numbered)
numbered.mecenat_receipt_number = "202600041"
session.add(numbered)
session.commit()
other = make_volunteer(session, edition, first_name="Sam", last_name="Petit")
_mark_mecenat_paid(session, other)
# Une autre année repart à 1.
next_year = make_volunteer(session, edition, first_name="Jo", last_name="Dupuis")
_mark_mecenat_paid(session, next_year, paid_at=dt.datetime(2027, 1, 2, 9, 0))
client.get(_url(edition, "/mecenat-receipts"))
session.refresh(other)
session.refresh(next_year)
assert other.mecenat_receipt_number == "202600042"
assert next_year.mecenat_receipt_number == "202700001"
def test_mecenat_receipts_is_422_without_any_paid_donor(client, edition):
resp = client.get(_url(edition, "/mecenat-receipts"))
assert resp.status_code == 422
+13 -2
View File
@@ -7,9 +7,10 @@ import io
import uuid
import pytest
from pypdf import PdfReader
from app.auth.rate_limit import form_submission_limiter, pending_status_limiter
from app.models import FormTemplate, PendingFormSubmission, Role, local_now
from app.models import FormTemplate, PendingFormSubmission, Role, Volunteer, local_now
from app.sumup import SumUpCheckout
from tests.factories import make_artist, make_position
from tests.mail import plain_text
@@ -920,7 +921,9 @@ def _attachments(message) -> list:
]
def test_mecenat_payment_emails_the_filled_cerfa(raw_client, client, edition, monkeypatch, outbox):
def test_mecenat_payment_emails_the_filled_cerfa(
raw_client, client, edition, session, monkeypatch, outbox
):
client.put(_form_url(edition, "/fields"), json={"fields": ["WILLING_TO_PAY_FEE", "EMAIL"]})
client.patch(
_form_url(edition), json={"sumup_merchant_code": _MERCHANT, "sumup_api_key": _API_KEY}
@@ -960,6 +963,14 @@ def test_mecenat_payment_emails_the_filled_cerfa(raw_client, client, edition, mo
assert attachments[0].get_content().startswith(b"%PDF")
assert "reçu fiscal" in plain_text(outbox[0])
# Numéro d'ordre attribué à la promotion, et imprimé sur le cerfa joint.
volunteer_id = _pending_status(raw_client, edition, token)["volunteer_id"]
volunteer = session.get(Volunteer, uuid.UUID(volunteer_id))
expected = f"{volunteer.fee_paid_at.year}00001"
assert volunteer.mecenat_receipt_number == expected
reader = PdfReader(io.BytesIO(attachments[0].get_content()))
assert expected in reader.pages[0].extract_text()
def test_classic_payment_email_has_no_cerfa(raw_client, client, edition, monkeypatch, outbox):
client.put(_form_url(edition, "/fields"), json={"fields": ["WILLING_TO_PAY_FEE", "EMAIL"]})