diff --git a/CLAUDE.md b/CLAUDE.md index 5232e8c..f65244a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -221,7 +221,16 @@ les tables sauf les tables d'association. signature (page 2) sont **pré-remplis dans le modèle PDF lui-même** par l'association (pas en base) : changer d'association = remplacer le fichier, en gardant la même mise en page (les coordonnées de l'overlay - n'ont été mesurées que sur ce document). + n'ont été mesurées que sur ce document). **Numéro d'ordre** du reçu + (case en haut de la page 1) : `Volunteer.mecenat_receipt_number`, unique, + `AAAA` (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 (`_promote_pending`) ou, pour un + bénévole qui n'en a pas encore, au premier export (dans l'ordre des + paiements), puis **jamais réattribué** (un reçu regénéré garde son + numéro). `app/mecenat/receipts.py:assign_receipt_number`, sérialisé par + un verrou `pg_advisory_xact_lock` (webhook et sondage peuvent confirmer + deux paiements à la fois). `pypdf` (nouvelle dépendance) fusionne l'overlay ReportLab sur la page du modèle. Bouton « Reçus mécénat (PDF) » sur l'écran de configuration des bénévoles, même mécanique de téléchargement que l'export Excel ; @@ -833,6 +842,7 @@ Aucune dépendance locale requise hors Docker. | 31 | Revue de sécurité : logo des emails en pièce jointe inline au lieu d'une URL (fuyait le `access_token` du formulaire public à tout destinataire — régression de l'étape 27, aucun test ne couvrait le rendu HTML du logo), sujet retiré du log de remise des emails (pouvait porter le nom d'un bénévole via un modèle personnalisé), image du formulaire public revalidée par décodage réel (`PIL`, pas seulement le `Content-Type` déclaré) avant stockage — évite qu'une fausse image fasse échouer la génération des badges/feuilles de poste, export Excel des bénévoles protégé contre l'injection de formule (CWE-1236, `app/exporting/xlsx.py:_safe_text`), sondage du statut de paiement en attente limité en fréquence par jeton (`pending_status_limiter`, évite un abus de l'API SumUp), `pyjwt` mis à jour (CVE listées non exploitables ici — HS256 à secret statique uniquement, jamais de JWKS — mais autant ne pas rester sur une version vulnérable) | à valider | | 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 | **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 3f62fa1..8f9e8a7 100644 --- a/backend/app/api/forms.py +++ b/backend/app/api/forms.py @@ -84,6 +84,7 @@ from app.forms import verify_hcaptcha from app.mecenat import ( RECEIPT_FILENAME, MecenatReceipt, + assign_receipt_number, generate_mecenat_receipts_pdf, receipt_for, ) @@ -795,6 +796,8 @@ def _promote_pending( ) session.add(volunteer) session.flush() + if volunteer.mecenat_address: + assign_receipt_number(session, volunteer) position_ids = _filter_owned_ids( session, Position, edition.id, _parse_id_list(pending.position_wish_ids_json) diff --git a/backend/app/api/volunteers.py b/backend/app/api/volunteers.py index 4a1988a..14a40d9 100644 --- a/backend/app/api/volunteers.py +++ b/backend/app/api/volunteers.py @@ -30,7 +30,7 @@ from app.auth.deps import ( ) from app.db import get_session from app.exporting import generate_volunteers_xlsx -from app.mecenat import generate_mecenat_receipts_pdf, receipt_for +from app.mecenat import assign_receipt_number, generate_mecenat_receipts_pdf, receipt_for from app.models import ( Account, Artist, @@ -247,6 +247,14 @@ def export_mecenat_receipts( status_code=422, detail={"code": "mecenat.no_receipts"} ) + # Bénévoles payés avant la numérotation (ou jamais exportés) : numéro + # attribué maintenant, dans l'ordre des paiements, puis figé. + unnumbered = [v for v in volunteers if not v.mecenat_receipt_number] + if unnumbered: + for v in sorted(unnumbered, key=lambda v: v.fee_paid_at or v.created_at): + assign_receipt_number(session, v) + session.commit() + receipts = [receipt_for(v) for v in volunteers] content = generate_mecenat_receipts_pdf(receipts) return Response( diff --git a/backend/app/mecenat/__init__.py b/backend/app/mecenat/__init__.py index 0a4b6fb..74e5d12 100644 --- a/backend/app/mecenat/__init__.py +++ b/backend/app/mecenat/__init__.py @@ -1,4 +1,10 @@ from app.mecenat.pdf import MecenatReceipt, generate_mecenat_receipts_pdf -from app.mecenat.receipts import RECEIPT_FILENAME, receipt_for +from app.mecenat.receipts import RECEIPT_FILENAME, assign_receipt_number, receipt_for -__all__ = ["RECEIPT_FILENAME", "MecenatReceipt", "generate_mecenat_receipts_pdf", "receipt_for"] +__all__ = [ + "RECEIPT_FILENAME", + "MecenatReceipt", + "assign_receipt_number", + "generate_mecenat_receipts_pdf", + "receipt_for", +] diff --git a/backend/app/mecenat/pdf.py b/backend/app/mecenat/pdf.py index 537926d..da03eef 100644 --- a/backend/app/mecenat/pdf.py +++ b/backend/app/mecenat/pdf.py @@ -55,6 +55,10 @@ _COUNTRY = _Field(x=67, bottom=310.5, max_width=125, font_size=9) _AMOUNT = _Field(x=44, bottom=365.2, max_width=110, font_size=9) _AMOUNT_IN_WORDS = _Field(x=356, bottom=363.1, max_width=190, font_size=8) _PAYMENT_DATE = _Field(x=184, bottom=385, max_width=76, font_size=9) +# Page 1 : case « Numéro d'ordre du reçu » (x 418,7 → 566,8, haut 111 → bas +# 128,2), texte centré dedans (`x` = centre de la case). +_RECEIPT_NUMBER = _Field(x=492.75, bottom=124.0, max_width=140, font_size=11, bold=True) +_CERFA_DATE = _Field(x=320, bottom=640, max_width=76, font_size=9) @dataclass(frozen=True) @@ -71,6 +75,7 @@ class MecenatReceipt: country: str amount: float payment_date: dt.date + number: str = "" def _fitted_font_size(text: str, max_width: float, font_size: float, bold: bool) -> float: @@ -93,6 +98,31 @@ def _draw(c: canvas.Canvas, text: str, field: _Field) -> None: c.drawString(field.x, _PAGE_H - field.bottom + _NUDGE, text) +def _draw_centered(c: canvas.Canvas, text: str, field: _Field) -> None: + """Comme `_draw`, mais `field.x` est le centre de la zone (case encadrée, + pas une ligne de pointillés : pas de `_NUDGE`).""" + if not text: + return + font_size = _fitted_font_size(text, field.max_width, field.font_size, field.bold) + c.setFont("Helvetica-Bold" if field.bold else "Helvetica", font_size) + c.setFillColor(black) + c.drawCentredString(field.x, _PAGE_H - field.bottom, text) + + +def _finish(c: canvas.Canvas, buffer: io.BytesIO) -> PageObject: + c.showPage() + c.save() + buffer.seek(0) + return PdfReader(buffer).pages[0] + + +def _organisation_overlay(receipt: MecenatReceipt) -> PageObject: + buffer = io.BytesIO() + c = canvas.Canvas(buffer, pagesize=(_PAGE_W, _PAGE_H)) + _draw_centered(c, receipt.number, _RECEIPT_NUMBER) + return _finish(c, buffer) + + def _overlay_page(receipt: MecenatReceipt) -> PageObject: buffer = io.BytesIO() c = canvas.Canvas(buffer, pagesize=(_PAGE_W, _PAGE_H)) @@ -105,15 +135,14 @@ def _overlay_page(receipt: MecenatReceipt) -> PageObject: _draw(c, f"{receipt.amount:.2f}".replace(".", ","), _AMOUNT) _draw(c, amount_in_words(receipt.amount), _AMOUNT_IN_WORDS) _draw(c, receipt.payment_date.strftime("%d/%m/%Y"), _PAYMENT_DATE) - c.showPage() - c.save() - buffer.seek(0) - return PdfReader(buffer).pages[0] + _draw(c, receipt.payment_date.strftime("%d/%m/%Y"), _CERFA_DATE) + return _finish(c, buffer) def generate_mecenat_receipts_pdf(receipts: list[MecenatReceipt]) -> bytes: - """Un reçu = les 2 pages du cerfa officiel, page 2 complétée avec les - informations du donateur, du montant et de la date — voir le docstring + """Un reçu = les 2 pages du cerfa officiel, page 1 complétée avec le + numéro d'ordre, page 2 avec les informations du donateur, du montant et + de la date — voir le docstring du module. Retourne les octets du PDF concaténant tous les reçus, un bénévole après l'autre. Le modèle est relu à chaque itération : `merge_page` mutant la page en place, réutiliser le même objet d'un tour à l'autre @@ -121,7 +150,8 @@ def generate_mecenat_receipts_pdf(receipts: list[MecenatReceipt]) -> bytes: writer = PdfWriter() for receipt in receipts: base = PdfReader(_TEMPLATE_PATH) - writer.add_page(base.pages[0]) + organisation_page = writer.add_page(base.pages[0]) + organisation_page.merge_page(_organisation_overlay(receipt)) # `add_page` renvoie la page telle qu'attachée au writer — fusionner # l'overlay sur ce retour plutôt que sur `base.pages[1]` (page encore # détachée d'un writer, `merge_page` dépréciée dans ce cas). diff --git a/backend/app/mecenat/receipts.py b/backend/app/mecenat/receipts.py index 3e78ec2..09dfc38 100644 --- a/backend/app/mecenat/receipts.py +++ b/backend/app/mecenat/receipts.py @@ -1,13 +1,46 @@ """Passage d'un `Volunteer` à un `MecenatReceipt` — partagé par l'export coordination (`app/api/volunteers.py:export_mecenat_receipts`) et l'envoi du reçu au bénévole à la confirmation de son paiement (`app/api/forms.py: -_promote_pending`).""" +_promote_pending`) — et attribution de son numéro d'ordre.""" + +from sqlalchemy import func, text +from sqlmodel import Session, select from app.mecenat.pdf import MecenatReceipt from app.models import Volunteer RECEIPT_FILENAME = "recu-fiscal-mecenat.pdf" +# Verrou applicatif PostgreSQL (`pg_advisory_xact_lock`) sérialisant +# l'attribution des numéros : le webhook SumUp et le sondage du navigateur +# peuvent confirmer deux paiements en même temps — sans lui, les deux +# liraient le même maximum et se disputeraient le même numéro. Libéré +# automatiquement à la fin de la transaction. Valeur arbitraire, propre à +# cet usage. +_NUMBERING_LOCK = 11580_05 + + +def assign_receipt_number(session: Session, volunteer: Volunteer) -> None: + """Attribue (sans commit) le prochain numéro de l'année du paiement : + `AAAA` + rang sur 5 chiffres, à la suite du plus grand déjà attribué + cette année-là, toutes éditions confondues (une seule numérotation pour + l'association). Ne fait rien si le bénévole en a déjà un — un numéro + n'est jamais réattribué.""" + if volunteer.mecenat_receipt_number: + return + session.exec(text("SELECT pg_advisory_xact_lock(:key)").bindparams(key=_NUMBERING_LOCK)) + year = (volunteer.fee_paid_at or volunteer.created_at).year + prefix = str(year) + last = session.exec( + select(func.max(Volunteer.mecenat_receipt_number)).where( + Volunteer.mecenat_receipt_number.startswith(prefix) + ) + ).one() + rank = int(last[len(prefix):]) + 1 if last else 1 + volunteer.mecenat_receipt_number = f"{prefix}{rank:05d}" + session.add(volunteer) + session.flush() + def receipt_for(volunteer: Volunteer) -> MecenatReceipt: return MecenatReceipt( @@ -19,4 +52,5 @@ def receipt_for(volunteer: Volunteer) -> MecenatReceipt: country=volunteer.mecenat_country or "", amount=volunteer.fee_amount or 0.0, payment_date=(volunteer.fee_paid_at or volunteer.created_at).date(), + number=volunteer.mecenat_receipt_number or "", ) diff --git a/backend/app/migrations/versions/0020_mecenat_receipt_number.py b/backend/app/migrations/versions/0020_mecenat_receipt_number.py new file mode 100644 index 0000000..32a1256 --- /dev/null +++ b/backend/app/migrations/versions/0020_mecenat_receipt_number.py @@ -0,0 +1,35 @@ +"""Numéro d'ordre des reçus fiscaux mécénat + +Revision ID: 0020_mecenat_receipt_number +Revises: 0019_form_access_token +Create Date: 2026-10-03 + +`volunteer.mecenat_receipt_number` : numéro unique du cerfa (`AAAA` + rang +sur 5 chiffres, ex. `202600001`) — voir `app/mecenat/receipts.py`. Les +bénévoles déjà payés en mécénat n'en reçoivent pas ici : ils sont numérotés +au premier export des reçus, dans l'ordre de leurs paiements. +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "0020_mecenat_receipt_number" +down_revision: str | None = "0019_form_access_token" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.add_column( + "volunteer", sa.Column("mecenat_receipt_number", sa.String(), nullable=True) + ) + op.create_unique_constraint( + "volunteer_mecenat_receipt_number_key", "volunteer", ["mecenat_receipt_number"] + ) + + +def downgrade() -> None: + op.drop_constraint("volunteer_mecenat_receipt_number_key", "volunteer", type_="unique") + op.drop_column("volunteer", "mecenat_receipt_number") diff --git a/backend/app/models/domain.py b/backend/app/models/domain.py index 468bc95..b6b2bec 100644 --- a/backend/app/models/domain.py +++ b/backend/app/models/domain.py @@ -221,6 +221,11 @@ class Volunteer(TimestampMixin, table=True): mecenat_city: str | None = None mecenat_postal_code: str | None = None mecenat_country: str | None = None + # Numéro d'ordre du reçu fiscal (cerfa) : année du paiement + rang sur 5 + # chiffres (`202600001`), attribué une seule fois — à la confirmation du + # paiement ou, à défaut, au premier export — puis figé, pour qu'un reçu + # regénéré garde son numéro. Voir `app/mecenat/receipts.py`. + mecenat_receipt_number: str | None = Field(default=None, unique=True) wished_artists: list[Artist] = Relationship( back_populates="fans", diff --git a/backend/tests/test_api_volunteers.py b/backend/tests/test_api_volunteers.py index dff96b7..6546275 100644 --- a/backend/tests/test_api_volunteers.py +++ b/backend/tests/test_api_volunteers.py @@ -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 diff --git a/backend/tests/test_forms_api.py b/backend/tests/test_forms_api.py index 456dd78..d2620dc 100644 --- a/backend/tests/test_forms_api.py +++ b/backend/tests/test_forms_api.py @@ -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"]})