Files
benevoles/backend/app/mecenat/pdf.py
T
AdamThomasandClaude Opus 5.5 c60bc11795
ci-cd / build-backend (push) Successful in 28s
ci-cd / build-frontend (push) Successful in 17s
ci-cd / deploy (push) Successful in 12s
Cerfa mécénat : mode de versement coché et dates repositionnées
- nouveau modèle PDF du cerfa
- case « Virement, prélèvement, carte bancaire » cochée (paiement SumUp)
- date du versement et date de signature repositionnées
- message de confirmation du paiement : annonce l'email de confirmation
  et le reçu fiscal pour le mécénat

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 16:34:22 +02:00

165 lines
6.6 KiB
Python

"""Génération des reçus fiscaux (cerfa 11580*05) pour les dons « mécénat »
de la cotisation — voir `app/api/volunteers.py:generate_mecenat_receipts`.
Module isolé, fonction pure (`generate_mecenat_receipts_pdf`) : aucun accès
base ici. Le cerfa officiel (`cerfa-mecenat-particuliers.pdf`, à côté de ce
fichier) n'est pas un formulaire PDF interactif (aucun champ AcroForm) — les
informations du donateur, du montant et de la date sont donc dessinées
par-dessus la page 2 du modèle, aux coordonnées mesurées sur ce document
précis (mise en page fixe d'un cerfa officiel, jamais destinée à changer).
Les informations de l'organisme bénéficiaire (page 1), les cases articles
200/978 et la signature (page 2) sont pré-remplies dans le modèle lui-même
par l'association — rien à dessiner ici.
"""
import datetime as dt
import io
from dataclasses import dataclass
from pathlib import Path
from pypdf import PageObject, PdfReader, PdfWriter
from reportlab.lib.colors import black
from reportlab.pdfbase.pdfmetrics import stringWidth
from reportlab.pdfgen import canvas
from app.mecenat.number_words import amount_in_words
_TEMPLATE_PATH = Path(__file__).parent / "cerfa-mecenat-particuliers.pdf"
_PAGE_W = 595.0
_PAGE_H = 842.0
# `bottom` : ligne de base du champ imprimé (pointillés) sur lequel écrire,
# en points depuis le HAUT de la page (repère pdfplumber, utilisé pour
# mesurer ces coordonnées) ; `_NUDGE` replace le texte juste au-dessus de
# cette ligne plutôt que dessus, une fois converti en repère PDF (origine en
# bas — voir `_draw`).
_NUDGE = 1.5
@dataclass(frozen=True)
class _Field:
x: float
bottom: float
max_width: float
font_size: float
bold: bool = False
_LAST_NAME = _Field(x=66, bottom=250.5, max_width=230, font_size=9)
_FIRST_NAME = _Field(x=352, bottom=250.5, max_width=190, font_size=9)
_ADDRESS = _Field(x=136, bottom=281.2, max_width=405, font_size=9)
_POSTAL_CODE = _Field(x=99, bottom=295.5, max_width=33, font_size=8)
_CITY = _Field(x=232, bottom=295.5, max_width=310, font_size=9)
_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=264, 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=630, max_width=76, font_size=9)
_CHECK_VIRMENT = _Field(x=291.8, bottom=592.1, max_width=10, font_size=8)
@dataclass(frozen=True)
class MecenatReceipt:
"""Un reçu = un bénévole ayant payé un type de paiement « mécénat » —
voir `Volunteer.mecenat_*`/`fee_*` (CLAUDE.md § 2, Paiement de la
cotisation)."""
last_name: str
first_name: str
address: str
postal_code: str
city: str
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:
"""Réduit la taille de police jusqu'à ce que `text` tienne dans
`max_width` (plancher 6 pt, en dessous le texte reste simplement
tronqué par le tracé plutôt que rendu illisible)."""
font = "Helvetica-Bold" if bold else "Helvetica"
size = font_size
while size > 6 and stringWidth(text, font, size) > max_width:
size -= 0.5
return size
def _draw(c: canvas.Canvas, text: str, field: _Field) -> None:
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.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))
_draw(c, receipt.last_name, _LAST_NAME)
_draw(c, receipt.first_name, _FIRST_NAME)
_draw(c, receipt.address, _ADDRESS)
_draw(c, receipt.postal_code, _POSTAL_CODE)
_draw(c, receipt.city, _CITY)
_draw(c, receipt.country, _COUNTRY)
_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)
_draw(c, receipt.payment_date.strftime("%d/%m/%Y"), _CERFA_DATE)
_draw(c, "X", _CHECK_VIRMENT)
return _finish(c, buffer)
def generate_mecenat_receipts_pdf(receipts: list[MecenatReceipt]) -> bytes:
"""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
accumulerait les tracés d'un bénévole sur la page du suivant."""
writer = PdfWriter()
for receipt in receipts:
base = PdfReader(_TEMPLATE_PATH)
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).
donor_page = writer.add_page(base.pages[1])
donor_page.merge_page(_overlay_page(receipt))
buffer = io.BytesIO()
writer.write(buffer)
return buffer.getvalue()