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
@@ -216,9 +216,12 @@ les tables sauf les tables d'association.
|
|||||||
`app/mecenat/number_words.py:amount_in_words` — conversion française pure,
|
`app/mecenat/number_words.py:amount_in_words` — conversion française pure,
|
||||||
testée séparément des coordonnées PDF vu le nombre d'exceptions
|
testée séparément des coordonnées PDF vu le nombre d'exceptions
|
||||||
orthographiques : accord de « quatre-vingts »/« cents », « et onze »
|
orthographiques : accord de « quatre-vingts »/« cents », « et onze »
|
||||||
irrégulier à 71) et la date du versement sont complétés ; l'encadré
|
irrégulier à 71) et la date du versement sont complétés. L'encadré
|
||||||
« Organisme bénéficiaire » (page 1) reste vierge, à la main,
|
« Organisme bénéficiaire » (page 1), les cases articles 200/978 et la
|
||||||
volontairement — pas de données de l'association en base à ce jour.
|
signature (page 2) sont **pré-remplis dans le modèle PDF lui-même** par
|
||||||
|
l'association (pas en base) : changer d'association = remplacer le
|
||||||
|
fichier, en gardant la même mise en page (les coordonnées de l'overlay
|
||||||
|
n'ont été mesurées que sur ce document).
|
||||||
`pypdf` (nouvelle dépendance) fusionne l'overlay ReportLab sur la page du
|
`pypdf` (nouvelle dépendance) fusionne l'overlay ReportLab sur la page du
|
||||||
modèle. Bouton « Reçus mécénat (PDF) » sur l'écran de configuration des
|
modèle. Bouton « Reçus mécénat (PDF) » sur l'écran de configuration des
|
||||||
bénévoles, même mécanique de téléchargement que l'export Excel ;
|
bénévoles, même mécanique de téléchargement que l'export Excel ;
|
||||||
@@ -484,6 +487,13 @@ ni corps**). Config : `SMTP_HOST/PORT/USER/PASSWORD/STARTTLS/SSL/FROM`,
|
|||||||
mécanisme pour que SumUp envoie elle-même un reçu au payeur (son
|
mécanisme pour que SumUp envoie elle-même un reçu au payeur (son
|
||||||
« envoi automatique de reçu » vise le lecteur de carte/point de vente, pas
|
« envoi automatique de reçu » vise le lecteur de carte/point de vente, pas
|
||||||
ce mode d'intégration), donc l'appli ne compte pas dessus.
|
ce mode d'intégration), donc l'appli ne compte pas dessus.
|
||||||
|
Paiement « mécénat » (adresse postale saisie) : ce même email porte en
|
||||||
|
pièce jointe le cerfa 11580*05 rempli du bénévole
|
||||||
|
(`app/mecenat/receipts.py:receipt_for` + `generate_mecenat_receipts_pdf`,
|
||||||
|
le même rendu que l'export coordination — organisme bénéficiaire et
|
||||||
|
signature pré-remplis dans le modèle). Généré dans la tâche de fond
|
||||||
|
(`_send_paid_confirmation`) ; un échec de génération envoie l'email sans
|
||||||
|
pièce jointe plutôt que rien.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -822,6 +832,7 @@ Aucune dépendance locale requise hors Docker.
|
|||||||
| 30 | Infobulles (`HelpTip` et détail de cellule de la grille des besoins) repositionnées en `position: fixed` via JS (`lib/useTooltipPosition.ts`), bornées à l'intérieur de l'écran et basculant de côté si la place manque — corrige un centrage `absolute` qui les faisait déborder de l'écran près d'un bord, et une isolation `z-index` qui rendait celle de la grille invisible (coincée sous les en-têtes figés) | à valider |
|
| 30 | Infobulles (`HelpTip` et détail de cellule de la grille des besoins) repositionnées en `position: fixed` via JS (`lib/useTooltipPosition.ts`), bornées à l'intérieur de l'écran et basculant de côté si la place manque — corrige un centrage `absolute` qui les faisait déborder de l'écran près d'un bord, et une isolation `z-index` qui rendait celle de la grille invisible (coincée sous les en-têtes figés) | à valider |
|
||||||
| 31 | Revue de sécurité : logo des emails en pièce jointe inline au lieu d'une URL (fuyait le `access_token` du formulaire public à tout destinataire — régression de l'étape 27, aucun test ne couvrait le rendu HTML du logo), sujet retiré du log de remise des emails (pouvait porter le nom d'un bénévole via un modèle personnalisé), image du formulaire public revalidée par décodage réel (`PIL`, pas seulement le `Content-Type` déclaré) avant stockage — évite qu'une fausse image fasse échouer la génération des badges/feuilles de poste, export Excel des bénévoles protégé contre l'injection de formule (CWE-1236, `app/exporting/xlsx.py:_safe_text`), sondage du statut de paiement en attente limité en fréquence par jeton (`pending_status_limiter`, évite un abus de l'API SumUp), `pyjwt` mis à jour (CVE listées non exploitables ici — HS256 à secret statique uniquement, jamais de JWKS — mais autant ne pas rester sur une version vulnérable) | à valider |
|
| 31 | Revue de sécurité : logo des emails en pièce jointe inline au lieu d'une URL (fuyait le `access_token` du formulaire public à tout destinataire — régression de l'étape 27, aucun test ne couvrait le rendu HTML du logo), sujet retiré du log de remise des emails (pouvait porter le nom d'un bénévole via un modèle personnalisé), image du formulaire public revalidée par décodage réel (`PIL`, pas seulement le `Content-Type` déclaré) avant stockage — évite qu'une fausse image fasse échouer la génération des badges/feuilles de poste, export Excel des bénévoles protégé contre l'injection de formule (CWE-1236, `app/exporting/xlsx.py:_safe_text`), sondage du statut de paiement en attente limité en fréquence par jeton (`pending_status_limiter`, évite un abus de l'API SumUp), `pyjwt` mis à jour (CVE listées non exploitables ici — HS256 à secret statique uniquement, jamais de JWKS — mais autant ne pas rester sur une version vulnérable) | à valider |
|
||||||
| 32 | Formulaire public : astérisque rouge sur les champs obligatoires (`RequiredMark`, `FieldLabel required`) et légende « * champ obligatoire » | à valider |
|
| 32 | Formulaire public : astérisque rouge sur les champs obligatoires (`RequiredMark`, `FieldLabel required`) et légende « * champ obligatoire » | à valider |
|
||||||
|
| 33 | Cerfa mécénat rempli envoyé en pièce jointe de l'email de confirmation une fois le paiement confirmé (`build_message(attachments=…)`, `app/mecenat/receipts.py`) ; modèle cerfa pré-rempli avec l'association et sa signature | à valider |
|
||||||
|
|
||||||
**Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur
|
**Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur
|
||||||
de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans +
|
de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans +
|
||||||
|
|||||||
@@ -81,6 +81,12 @@ from app.email import (
|
|||||||
send,
|
send,
|
||||||
)
|
)
|
||||||
from app.forms import verify_hcaptcha
|
from app.forms import verify_hcaptcha
|
||||||
|
from app.mecenat import (
|
||||||
|
RECEIPT_FILENAME,
|
||||||
|
MecenatReceipt,
|
||||||
|
generate_mecenat_receipts_pdf,
|
||||||
|
receipt_for,
|
||||||
|
)
|
||||||
from app.models import (
|
from app.models import (
|
||||||
Account,
|
Account,
|
||||||
Artist,
|
Artist,
|
||||||
@@ -822,21 +828,48 @@ def _promote_pending(
|
|||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
if volunteer.email:
|
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(
|
subject, body = render_registration_paid_confirmation(
|
||||||
first_name=volunteer.first_name,
|
first_name=volunteer.first_name,
|
||||||
label=volunteer.fee_payment_label or "",
|
label=volunteer.fee_payment_label or "",
|
||||||
amount=volunteer.fee_amount or 0,
|
amount=volunteer.fee_amount or 0,
|
||||||
|
with_mecenat_receipt=receipt is not None,
|
||||||
)
|
)
|
||||||
background.add_task(
|
background.add_task(
|
||||||
send,
|
_send_paid_confirmation,
|
||||||
volunteer.email,
|
volunteer.email,
|
||||||
subject,
|
subject,
|
||||||
body,
|
body,
|
||||||
logo=edition_logo(session, edition.id),
|
logo=edition_logo(session, edition.id),
|
||||||
|
receipt=receipt,
|
||||||
)
|
)
|
||||||
return volunteer
|
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(
|
def _apply_pending_checkout_result(
|
||||||
session: Session,
|
session: Session,
|
||||||
edition: Edition,
|
edition: Edition,
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ from app.auth.deps import (
|
|||||||
)
|
)
|
||||||
from app.db import get_session
|
from app.db import get_session
|
||||||
from app.exporting import generate_volunteers_xlsx
|
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 (
|
from app.models import (
|
||||||
Account,
|
Account,
|
||||||
Artist,
|
Artist,
|
||||||
@@ -247,19 +247,7 @@ def export_mecenat_receipts(
|
|||||||
status_code=422, detail={"code": "mecenat.no_receipts"}
|
status_code=422, detail={"code": "mecenat.no_receipts"}
|
||||||
)
|
)
|
||||||
|
|
||||||
receipts = [
|
receipts = [receipt_for(v) for v in volunteers]
|
||||||
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
|
|
||||||
]
|
|
||||||
content = generate_mecenat_receipts_pdf(receipts)
|
content = generate_mecenat_receipts_pdf(receipts)
|
||||||
return Response(
|
return Response(
|
||||||
content=content,
|
content=content,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ Pour diagnostiquer : `python -m app.email.check <destinataire>`."""
|
|||||||
import logging
|
import logging
|
||||||
import smtplib
|
import smtplib
|
||||||
import ssl
|
import ssl
|
||||||
from collections.abc import Iterable
|
from collections.abc import Iterable, Sequence
|
||||||
from email.message import EmailMessage
|
from email.message import EmailMessage
|
||||||
|
|
||||||
from app.config import Settings, get_settings
|
from app.config import Settings, get_settings
|
||||||
@@ -33,8 +33,17 @@ def _smtp_error_summary(exc: smtplib.SMTPException) -> str:
|
|||||||
return exc.__class__.__name__
|
return exc.__class__.__name__
|
||||||
|
|
||||||
|
|
||||||
|
# (octets, content-type, nom de fichier) d'une pièce jointe.
|
||||||
|
Attachment = tuple[bytes, str, str]
|
||||||
|
|
||||||
|
|
||||||
def build_message(
|
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:
|
) -> EmailMessage:
|
||||||
"""`logo` = (octets, content-type) (voir `app/email/branding.py:
|
"""`logo` = (octets, content-type) (voir `app/email/branding.py:
|
||||||
edition_logo`) : ajoute une version HTML au thème de l'appli
|
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 »
|
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
|
(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
|
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 = EmailMessage()
|
||||||
message["From"] = get_settings().smtp_from
|
message["From"] = get_settings().smtp_from
|
||||||
message["To"] = to
|
message["To"] = to
|
||||||
@@ -60,6 +71,9 @@ def build_message(
|
|||||||
html_part.add_related(
|
html_part.add_related(
|
||||||
content, maintype or "image", subtype or "png", cid=f"<{LOGO_CID}>"
|
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
|
return message
|
||||||
|
|
||||||
|
|
||||||
@@ -131,8 +145,15 @@ def deliver_many(messages: list[EmailMessage]) -> None:
|
|||||||
logger.info("Emails remis : %d/%d", sent, len(messages))
|
logger.info("Emails remis : %d/%d", sent, len(messages))
|
||||||
|
|
||||||
|
|
||||||
def send(to: str, subject: str, body: str, *, logo: tuple[bytes, str] | None = None) -> None:
|
def send(
|
||||||
deliver_many([build_message(to, subject, body, logo=logo)])
|
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(
|
def send_many(
|
||||||
|
|||||||
@@ -32,22 +32,23 @@ def render_registration_confirmation(*, first_name: str) -> tuple[str, str]:
|
|||||||
|
|
||||||
|
|
||||||
def render_registration_paid_confirmation(
|
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]:
|
) -> tuple[str, str]:
|
||||||
"""Inscription prise en compte **et** paiement en ligne confirmé — envoyé
|
"""Inscription prise en compte **et** paiement en ligne confirmé — envoyé
|
||||||
une fois seulement, au moment de la promotion (`app/api/forms.py:
|
une fois seulement, au moment de la promotion (`app/api/forms.py:
|
||||||
_promote_pending`). Sert de reçu (montant, type de paiement) en
|
_promote_pending`). Sert de reçu (montant, type de paiement) en
|
||||||
l'absence de confirmation documentée que l'API SumUp Hosted Checkout en
|
l'absence de confirmation documentée que l'API SumUp Hosted Checkout en
|
||||||
envoie un elle-même."""
|
envoie un elle-même. `with_mecenat_receipt` : le cerfa rempli est joint
|
||||||
return (
|
(`_promote_pending`), le corps l'annonce."""
|
||||||
t("email.registration_confirmation_paid.subject"),
|
body = t(
|
||||||
t(
|
|
||||||
"email.registration_confirmation_paid.body",
|
"email.registration_confirmation_paid.body",
|
||||||
prenom=first_name,
|
prenom=first_name,
|
||||||
label=label,
|
label=label,
|
||||||
montant=amount,
|
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:
|
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.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.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.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_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.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.",
|
"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.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
|
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
|
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).
|
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
|
Les informations de l'organisme bénéficiaire (page 1), les cases articles
|
||||||
bénéficiaire ») ne sont volontairement pas renseignées : à compléter à la
|
200/978 et la signature (page 2) sont pré-remplies dans le modèle lui-même
|
||||||
main par la coordination avant remise au bénévole.
|
par l'association — rien à dessiner ici.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import datetime as dt
|
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)
|
_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 = _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)
|
_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)
|
@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(),
|
||||||
|
)
|
||||||
@@ -912,6 +912,81 @@ def test_choosing_a_mecenat_option_requires_the_address_step_first(
|
|||||||
assert fiche["mecenat_country"] == "France"
|
assert fiche["mecenat_country"] == "France"
|
||||||
|
|
||||||
|
|
||||||
|
def _attachments(message) -> list:
|
||||||
|
return [
|
||||||
|
part
|
||||||
|
for part in message.iter_attachments()
|
||||||
|
if part.get_content_disposition() == "attachment"
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_mecenat_payment_emails_the_filled_cerfa(raw_client, client, edition, 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}
|
||||||
|
)
|
||||||
|
option_id = client.post(
|
||||||
|
_form_url(edition, "/payment-options"),
|
||||||
|
json={"label": "Mécénat", "amount": 50, "requires_mecenat_info": True},
|
||||||
|
).json()["id"]
|
||||||
|
token = _submit(
|
||||||
|
raw_client, edition,
|
||||||
|
first_name="Alex", last_name="Martin", hcaptcha_token="x",
|
||||||
|
email="alex@example.org",
|
||||||
|
payment_option_id=option_id,
|
||||||
|
).json()["pending_token"]
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.api.forms.create_checkout",
|
||||||
|
lambda **kw: SumUpCheckout(id="co_don", status="PENDING", hosted_checkout_url="https://don"),
|
||||||
|
)
|
||||||
|
raw_client.post(
|
||||||
|
_form_url(edition, f"/public/pending/{token}/mecenat-info"),
|
||||||
|
json={
|
||||||
|
"address": "1 rue du Festival",
|
||||||
|
"city": "Rennes",
|
||||||
|
"postal_code": "35000",
|
||||||
|
"country": "France",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert outbox == [] # pas encore payé : ni email ni reçu
|
||||||
|
|
||||||
|
_pay(raw_client, edition, monkeypatch, checkout_id="co_don")
|
||||||
|
|
||||||
|
assert len(outbox) == 1
|
||||||
|
attachments = _attachments(outbox[0])
|
||||||
|
assert len(attachments) == 1
|
||||||
|
assert attachments[0].get_content_type() == "application/pdf"
|
||||||
|
assert attachments[0].get_filename().endswith(".pdf")
|
||||||
|
assert attachments[0].get_content().startswith(b"%PDF")
|
||||||
|
assert "reçu fiscal" in plain_text(outbox[0])
|
||||||
|
|
||||||
|
|
||||||
|
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"]})
|
||||||
|
client.patch(
|
||||||
|
_form_url(edition), json={"sumup_merchant_code": _MERCHANT, "sumup_api_key": _API_KEY}
|
||||||
|
)
|
||||||
|
option_id = client.post(
|
||||||
|
_form_url(edition, "/payment-options"),
|
||||||
|
json={"label": "Cotisation classique", "amount": 12.5},
|
||||||
|
).json()["id"]
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.api.forms.create_checkout",
|
||||||
|
lambda **kw: SumUpCheckout(id="co_1", status="PENDING", hosted_checkout_url="https://x"),
|
||||||
|
)
|
||||||
|
_submit(
|
||||||
|
raw_client, edition,
|
||||||
|
first_name="Alex", last_name="Martin", hcaptcha_token="x",
|
||||||
|
email="alex@example.org",
|
||||||
|
payment_option_id=option_id,
|
||||||
|
)
|
||||||
|
_pay(raw_client, edition, monkeypatch, checkout_id="co_1")
|
||||||
|
|
||||||
|
assert len(outbox) == 1
|
||||||
|
assert _attachments(outbox[0]) == []
|
||||||
|
assert "reçu fiscal" not in plain_text(outbox[0])
|
||||||
|
|
||||||
|
|
||||||
def test_mecenat_info_requires_every_field(raw_client, client, edition, monkeypatch):
|
def test_mecenat_info_requires_every_field(raw_client, client, edition, monkeypatch):
|
||||||
client.put(_form_url(edition, "/fields"), json={"fields": ["WILLING_TO_PAY_FEE"]})
|
client.put(_form_url(edition, "/fields"), json={"fields": ["WILLING_TO_PAY_FEE"]})
|
||||||
client.patch(
|
client.patch(
|
||||||
@@ -1002,3 +1077,4 @@ def test_pending_status_polling_is_rate_limited_per_token(
|
|||||||
for _ in range(pending_status_limiter.max_attempts):
|
for _ in range(pending_status_limiter.max_attempts):
|
||||||
pending_status_limiter.hit(token)
|
pending_status_limiter.hit(token)
|
||||||
assert raw_client.get(_form_url(edition, f"/public/pending/{token}")).status_code == 429
|
assert raw_client.get(_form_url(edition, f"/public/pending/{token}")).status_code == 429
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user