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:
co-authored by
Claude Opus 5.5
parent
5a0209ce43
commit
ccd769d04e
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 "",
|
||||
)
|
||||
|
||||
@@ -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")
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]})
|
||||
|
||||
Reference in New Issue
Block a user