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