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>
163 lines
6.5 KiB
Python
163 lines
6.5 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=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)
|
|
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)
|
|
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()
|