Cerfa mécénat rempli envoyé par email à la confirmation du paiement

Quand un paiement « mécénat » est confirmé, l'email de confirmation porte
en pièce jointe le reçu fiscal (cerfa 11580*05) rempli du bénévole, avec
une phrase l'annonçant dans le corps. Généré en tâche de fond : un échec
de génération envoie l'email sans pièce jointe plutôt que rien.

- build_message/send acceptent des pièces jointes
- app/mecenat/receipts.py : passage Volunteer → MecenatReceipt, partagé
  avec l'export coordination
- modèle cerfa remplacé par une version pré-remplie avec l'association
  (organisme bénéficiaire, cases 200/978, signature) ; position de la
  date du versement ajustée

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
AdamThomas
2026-10-03 13:47:45 +02:00
co-authored by Claude Opus 5.5
parent 5675e1d220
commit 5a0209ce43
11 changed files with 192 additions and 38 deletions
+34 -1
View File
@@ -81,6 +81,12 @@ from app.email import (
send,
)
from app.forms import verify_hcaptcha
from app.mecenat import (
RECEIPT_FILENAME,
MecenatReceipt,
generate_mecenat_receipts_pdf,
receipt_for,
)
from app.models import (
Account,
Artist,
@@ -822,21 +828,48 @@ def _promote_pending(
session.commit()
if volunteer.email:
# Paiement « mécénat » (adresse postale saisie) : le cerfa rempli part
# en pièce jointe du même email — voir `_send_paid_confirmation`.
receipt = receipt_for(volunteer) if volunteer.mecenat_address else None
subject, body = render_registration_paid_confirmation(
first_name=volunteer.first_name,
label=volunteer.fee_payment_label or "",
amount=volunteer.fee_amount or 0,
with_mecenat_receipt=receipt is not None,
)
background.add_task(
send,
_send_paid_confirmation,
volunteer.email,
subject,
body,
logo=edition_logo(session, edition.id),
receipt=receipt,
)
return volunteer
def _send_paid_confirmation(
to: str,
subject: str,
body: str,
*,
logo: tuple[bytes, str] | None,
receipt: MecenatReceipt | None,
) -> None:
"""Tâche de fond : génère le cerfa (si mécénat) puis envoie. Best effort
comme le reste des emails — un échec de génération du PDF ne doit pas
priver le bénévole de sa confirmation, qui part alors sans pièce jointe
(la coordination garde l'export des reçus pour le lui renvoyer)."""
attachments = []
if receipt is not None:
try:
pdf = generate_mecenat_receipts_pdf([receipt])
attachments.append((pdf, "application/pdf", RECEIPT_FILENAME))
except Exception:
logger.exception("Reçu fiscal mécénat non généré — email envoyé sans pièce jointe")
send(to, subject, body, logo=logo, attachments=attachments)
def _apply_pending_checkout_result(
session: Session,
edition: Edition,
+2 -14
View File
@@ -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 MecenatReceipt, generate_mecenat_receipts_pdf
from app.mecenat import generate_mecenat_receipts_pdf, receipt_for
from app.models import (
Account,
Artist,
@@ -247,19 +247,7 @@ def export_mecenat_receipts(
status_code=422, detail={"code": "mecenat.no_receipts"}
)
receipts = [
MecenatReceipt(
last_name=v.last_name,
first_name=v.first_name,
address=v.mecenat_address or "",
postal_code=v.mecenat_postal_code or "",
city=v.mecenat_city or "",
country=v.mecenat_country or "",
amount=v.fee_amount or 0.0,
payment_date=(v.fee_paid_at or v.created_at).date(),
)
for v in volunteers
]
receipts = [receipt_for(v) for v in volunteers]
content = generate_mecenat_receipts_pdf(receipts)
return Response(
content=content,
+26 -5
View File
@@ -8,7 +8,7 @@ Pour diagnostiquer : `python -m app.email.check <destinataire>`."""
import logging
import smtplib
import ssl
from collections.abc import Iterable
from collections.abc import Iterable, Sequence
from email.message import EmailMessage
from app.config import Settings, get_settings
@@ -33,8 +33,17 @@ def _smtp_error_summary(exc: smtplib.SMTPException) -> str:
return exc.__class__.__name__
# (octets, content-type, nom de fichier) d'une pièce jointe.
Attachment = tuple[bytes, str, str]
def build_message(
to: str, subject: str, body: str, *, logo: tuple[bytes, str] | None = None
to: str,
subject: str,
body: str,
*,
logo: tuple[bytes, str] | None = None,
attachments: Sequence[Attachment] = (),
) -> EmailMessage:
"""`logo` = (octets, content-type) (voir `app/email/branding.py:
edition_logo`) : ajoute une version HTML au thème de l'appli
@@ -43,7 +52,9 @@ def build_message(
HTML. Le logo, s'il y en a un, est intégré en pièce jointe « inline »
(Content-ID) référencée par la partie HTML — jamais une URL externe, qui
aurait exposé le jeton d'accès du formulaire public à quiconque lit la
source du message (voir `app/email/branding.py`)."""
source du message (voir `app/email/branding.py`). `attachments` : vraies
pièces jointes (ex. reçu fiscal mécénat), ajoutées après le logo — le
message devient alors `multipart/mixed`."""
message = EmailMessage()
message["From"] = get_settings().smtp_from
message["To"] = to
@@ -60,6 +71,9 @@ def build_message(
html_part.add_related(
content, maintype or "image", subtype or "png", cid=f"<{LOGO_CID}>"
)
for content, content_type, filename in attachments:
maintype, _, subtype = content_type.partition("/")
message.add_attachment(content, maintype=maintype, subtype=subtype, filename=filename)
return message
@@ -131,8 +145,15 @@ def deliver_many(messages: list[EmailMessage]) -> None:
logger.info("Emails remis : %d/%d", sent, len(messages))
def send(to: str, subject: str, body: str, *, logo: tuple[bytes, str] | None = None) -> None:
deliver_many([build_message(to, subject, body, logo=logo)])
def send(
to: str,
subject: str,
body: str,
*,
logo: tuple[bytes, str] | None = None,
attachments: Sequence[Attachment] = (),
) -> None:
deliver_many([build_message(to, subject, body, logo=logo, attachments=attachments)])
def send_many(
+11 -10
View File
@@ -32,22 +32,23 @@ def render_registration_confirmation(*, first_name: str) -> tuple[str, str]:
def render_registration_paid_confirmation(
*, first_name: str, label: str, amount: float
*, first_name: str, label: str, amount: float, with_mecenat_receipt: bool = False
) -> tuple[str, str]:
"""Inscription prise en compte **et** paiement en ligne confirmé — envoyé
une fois seulement, au moment de la promotion (`app/api/forms.py:
_promote_pending`). Sert de reçu (montant, type de paiement) en
l'absence de confirmation documentée que l'API SumUp Hosted Checkout en
envoie un elle-même."""
return (
t("email.registration_confirmation_paid.subject"),
t(
"email.registration_confirmation_paid.body",
prenom=first_name,
label=label,
montant=amount,
),
envoie un elle-même. `with_mecenat_receipt` : le cerfa rempli est joint
(`_promote_pending`), le corps l'annonce."""
body = t(
"email.registration_confirmation_paid.body",
prenom=first_name,
label=label,
montant=amount,
)
if with_mecenat_receipt:
body += "\n\n" + t("email.registration_confirmation_paid.mecenat_receipt_note")
return t("email.registration_confirmation_paid.subject"), body
def _fill(template: str, values: dict[str, object]) -> str:
+1
View File
@@ -53,6 +53,7 @@
"email.registration_confirmation.body": "Bonjour {prenom},\n\nTon inscription comme bénévole a bien été enregistrée. La coordination du festival prendra contact avec toi si besoin.\n\nÀ bientôt !",
"email.registration_confirmation_paid.subject": "Ton inscription bénévole est confirmée — paiement reçu",
"email.registration_confirmation_paid.body": "Bonjour {prenom},\n\nTon inscription comme bénévole a bien été enregistrée, et nous confirmons la réception de ton paiement :\n\n{label} — {montant} €\n\nLa coordination du festival prendra contact avec toi si besoin.\n\nÀ bientôt !",
"email.registration_confirmation_paid.mecenat_receipt_note": "Tu trouveras en pièce jointe ton reçu fiscal au titre des dons (cerfa n° 11580*05), à conserver pour ta déclaration de revenus.",
"email.planning.default_subject": "Ton planning bénévole",
"email.planning.default_body": "Bonjour {prenom},\n\nTon planning a été mis à jour : {nb_creneaux} créneau(x), soit {nb_heures} h au total.\n\n{creneaux}\n\nÀ très vite sur le festival !",
"email.planning.no_shifts": "Aucun créneau ne t'est affecté pour le moment.",
+2 -1
View File
@@ -1,3 +1,4 @@
from app.mecenat.pdf import MecenatReceipt, generate_mecenat_receipts_pdf
from app.mecenat.receipts import RECEIPT_FILENAME, receipt_for
__all__ = ["MecenatReceipt", "generate_mecenat_receipts_pdf"]
__all__ = ["RECEIPT_FILENAME", "MecenatReceipt", "generate_mecenat_receipts_pdf", "receipt_for"]
Binary file not shown.
+4 -4
View File
@@ -7,9 +7,9 @@ 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, encadré « Organisme
bénéficiaire ») ne sont volontairement pas renseignées : à compléter à la
main par la coordination avant remise au bénévole.
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
@@ -54,7 +54,7 @@ _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=380.9, max_width=76, font_size=9)
_PAYMENT_DATE = _Field(x=184, bottom=385, max_width=76, font_size=9)
@dataclass(frozen=True)
+22
View File
@@ -0,0 +1,22 @@
"""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`)."""
from app.mecenat.pdf import MecenatReceipt
from app.models import Volunteer
RECEIPT_FILENAME = "recu-fiscal-mecenat.pdf"
def receipt_for(volunteer: Volunteer) -> MecenatReceipt:
return MecenatReceipt(
last_name=volunteer.last_name,
first_name=volunteer.first_name,
address=volunteer.mecenat_address or "",
postal_code=volunteer.mecenat_postal_code or "",
city=volunteer.mecenat_city or "",
country=volunteer.mecenat_country or "",
amount=volunteer.fee_amount or 0.0,
payment_date=(volunteer.fee_paid_at or volunteer.created_at).date(),
)