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
+11 -1
View File
@@ -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 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 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 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 `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 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 ; 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 | | 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 | | 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 | | 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 **Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur
de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans + de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans +
+3
View File
@@ -84,6 +84,7 @@ from app.forms import verify_hcaptcha
from app.mecenat import ( from app.mecenat import (
RECEIPT_FILENAME, RECEIPT_FILENAME,
MecenatReceipt, MecenatReceipt,
assign_receipt_number,
generate_mecenat_receipts_pdf, generate_mecenat_receipts_pdf,
receipt_for, receipt_for,
) )
@@ -795,6 +796,8 @@ def _promote_pending(
) )
session.add(volunteer) session.add(volunteer)
session.flush() session.flush()
if volunteer.mecenat_address:
assign_receipt_number(session, volunteer)
position_ids = _filter_owned_ids( position_ids = _filter_owned_ids(
session, Position, edition.id, _parse_id_list(pending.position_wish_ids_json) session, Position, edition.id, _parse_id_list(pending.position_wish_ids_json)
+9 -1
View File
@@ -30,7 +30,7 @@ from app.auth.deps import (
) )
from app.db import get_session from app.db import get_session
from app.exporting import generate_volunteers_xlsx 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 ( from app.models import (
Account, Account,
Artist, Artist,
@@ -247,6 +247,14 @@ def export_mecenat_receipts(
status_code=422, detail={"code": "mecenat.no_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] receipts = [receipt_for(v) for v in volunteers]
content = generate_mecenat_receipts_pdf(receipts) content = generate_mecenat_receipts_pdf(receipts)
return Response( return Response(
+8 -2
View File
@@ -1,4 +1,10 @@
from app.mecenat.pdf import MecenatReceipt, generate_mecenat_receipts_pdf 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",
]
+37 -7
View File
@@ -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 = _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) _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) _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) @dataclass(frozen=True)
@@ -71,6 +75,7 @@ class MecenatReceipt:
country: str country: str
amount: float amount: float
payment_date: dt.date payment_date: dt.date
number: str = ""
def _fitted_font_size(text: str, max_width: float, font_size: float, bold: bool) -> float: 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) 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: def _overlay_page(receipt: MecenatReceipt) -> PageObject:
buffer = io.BytesIO() buffer = io.BytesIO()
c = canvas.Canvas(buffer, pagesize=(_PAGE_W, _PAGE_H)) 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, f"{receipt.amount:.2f}".replace(".", ","), _AMOUNT)
_draw(c, amount_in_words(receipt.amount), _AMOUNT_IN_WORDS) _draw(c, amount_in_words(receipt.amount), _AMOUNT_IN_WORDS)
_draw(c, receipt.payment_date.strftime("%d/%m/%Y"), _PAYMENT_DATE) _draw(c, receipt.payment_date.strftime("%d/%m/%Y"), _PAYMENT_DATE)
c.showPage() _draw(c, receipt.payment_date.strftime("%d/%m/%Y"), _CERFA_DATE)
c.save() return _finish(c, buffer)
buffer.seek(0)
return PdfReader(buffer).pages[0]
def generate_mecenat_receipts_pdf(receipts: list[MecenatReceipt]) -> bytes: 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 """Un reçu = les 2 pages du cerfa officiel, page 1 complétée avec le
informations du donateur, du montant et de la date — voir le docstring 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 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` 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 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() writer = PdfWriter()
for receipt in receipts: for receipt in receipts:
base = PdfReader(_TEMPLATE_PATH) 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 # `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 # 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). # détachée d'un writer, `merge_page` dépréciée dans ce cas).
+35 -1
View File
@@ -1,13 +1,46 @@
"""Passage d'un `Volunteer` à un `MecenatReceipt` — partagé par l'export """Passage d'un `Volunteer` à un `MecenatReceipt` — partagé par l'export
coordination (`app/api/volunteers.py:export_mecenat_receipts`) et l'envoi du 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: 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.mecenat.pdf import MecenatReceipt
from app.models import Volunteer from app.models import Volunteer
RECEIPT_FILENAME = "recu-fiscal-mecenat.pdf" 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: def receipt_for(volunteer: Volunteer) -> MecenatReceipt:
return MecenatReceipt( return MecenatReceipt(
@@ -19,4 +52,5 @@ def receipt_for(volunteer: Volunteer) -> MecenatReceipt:
country=volunteer.mecenat_country or "", country=volunteer.mecenat_country or "",
amount=volunteer.fee_amount or 0.0, amount=volunteer.fee_amount or 0.0,
payment_date=(volunteer.fee_paid_at or volunteer.created_at).date(), payment_date=(volunteer.fee_paid_at or volunteer.created_at).date(),
number=volunteer.mecenat_receipt_number or "",
) )
@@ -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")
+5
View File
@@ -221,6 +221,11 @@ class Volunteer(TimestampMixin, table=True):
mecenat_city: str | None = None mecenat_city: str | None = None
mecenat_postal_code: str | None = None mecenat_postal_code: str | None = None
mecenat_country: 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( wished_artists: list[Artist] = Relationship(
back_populates="fans", back_populates="fans",
+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 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 » """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 (voir `app/api/forms.py:_promote_pending`) — `mecenat_address` est le
marqueur qui distingue ce chemin d'un simple paiement classique.""" marqueur qui distingue ce chemin d'un simple paiement classique."""
volunteer.fee_payment_status = FeePaymentStatus.PAID volunteer.fee_payment_status = FeePaymentStatus.PAID
volunteer.fee_amount = amount 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_address = "12 rue des Lilas"
volunteer.mecenat_city = "Paris" volunteer.mecenat_city = "Paris"
volunteer.mecenat_postal_code = "75020" 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 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): def test_mecenat_receipts_is_422_without_any_paid_donor(client, edition):
resp = client.get(_url(edition, "/mecenat-receipts")) resp = client.get(_url(edition, "/mecenat-receipts"))
assert resp.status_code == 422 assert resp.status_code == 422
+13 -2
View File
@@ -7,9 +7,10 @@ import io
import uuid import uuid
import pytest import pytest
from pypdf import PdfReader
from app.auth.rate_limit import form_submission_limiter, pending_status_limiter 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 app.sumup import SumUpCheckout
from tests.factories import make_artist, make_position from tests.factories import make_artist, make_position
from tests.mail import plain_text 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.put(_form_url(edition, "/fields"), json={"fields": ["WILLING_TO_PAY_FEE", "EMAIL"]})
client.patch( client.patch(
_form_url(edition), json={"sumup_merchant_code": _MERCHANT, "sumup_api_key": _API_KEY} _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 attachments[0].get_content().startswith(b"%PDF")
assert "reçu fiscal" in plain_text(outbox[0]) 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): 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"]}) client.put(_form_url(edition, "/fields"), json={"fields": ["WILLING_TO_PAY_FEE", "EMAIL"]})