Logs de diagnostic et rapprochement SumUp ↔ bénévole
- Une ligne de log par requête (route modèle, statut, durée, code d'erreur, compte), identifiant X-Request-ID repris dans chaque log et affiché à l'utilisateur sous une erreur, exceptions non gérées tracées (500 JSON) - Journal d'accès d'uvicorn coupé : il exposait le jeton du formulaire public - Logs métier : paiement SumUp, connexion, import, plannings, solveur - Nom du bénévole dans la description du checkout SumUp - Code et identifiant de transaction SumUp enregistrés au paiement (migration 0023), affichés sur la fiche et dans l'export Excel Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
d722a81a87
commit
d0fa1e109c
@@ -205,8 +205,8 @@ les tables sauf les tables d'association.
|
|||||||
- **Export bénévoles** (Excel, `app/exporting/xlsx.py` + `GET
|
- **Export bénévoles** (Excel, `app/exporting/xlsx.py` + `GET
|
||||||
/editions/{id}/volunteers/export`, COORDINATION) : une ligne par
|
/editions/{id}/volunteers/export`, COORDINATION) : une ligne par
|
||||||
bénévole, mêmes champs que la fiche (coordonnées, vœux, cotisation,
|
bénévole, mêmes champs que la fiche (coordonnées, vœux, cotisation,
|
||||||
mécénat) — les vœux (postes/artistes/compagnons) sont résolus en noms
|
mécénat, transaction SumUp — code et identifiant) — les vœux (postes/
|
||||||
lisibles, jamais en UUID. `openpyxl`, déjà utilisé en lecture par l'import
|
artistes/compagnons) sont résolus en noms lisibles, jamais en UUID. `openpyxl`, déjà utilisé en lecture par l'import
|
||||||
(`app/importing/`), cette fois en écriture. Bouton « Exporter en Excel »
|
(`app/importing/`), cette fois en écriture. Bouton « Exporter en Excel »
|
||||||
sur l'écran de configuration des bénévoles (`ConfigVolunteers.tsx`), même
|
sur l'écran de configuration des bénévoles (`ConfigVolunteers.tsx`), même
|
||||||
mécanique de téléchargement que les badges/feuilles de poste (blob →
|
mécanique de téléchargement que les badges/feuilles de poste (blob →
|
||||||
@@ -358,7 +358,17 @@ les tables sauf les tables d'association.
|
|||||||
ou supprimée ensuite. `Volunteer.fee_checkout_id` (identifiant SumUp,
|
ou supprimée ensuite. `Volunteer.fee_checkout_id` (identifiant SumUp,
|
||||||
jamais exposé) et `fee_payment_status` (`PENDING`/`PAID`/`FAILED`/
|
jamais exposé) et `fee_payment_status` (`PENDING`/`PAID`/`FAILED`/
|
||||||
`EXPIRED`, `NULL` tant qu'aucun checkout n'a été créé) suivent l'état ;
|
`EXPIRED`, `NULL` tant qu'aucun checkout n'a été créé) suivent l'état ;
|
||||||
`fee_paid_at` est posé à la première confirmation `PAID`. Une fois promue,
|
`fee_paid_at` est posé à la première confirmation `PAID`, avec la
|
||||||
|
transaction SumUp qui a réglé le checkout : `fee_transaction_code` (code
|
||||||
|
court du tableau de bord SumUp et de ses reçus, ex. `TEENSK4W2K` — lu sur
|
||||||
|
le checkout, ou à défaut sur son essai `SUCCESSFUL` dans `transactions`)
|
||||||
|
et `fee_transaction_id` (identifiant technique) — exposés sur la fiche
|
||||||
|
(section « Cotisation ») et dans l'export Excel, pour le rapprochement
|
||||||
|
comptable. Dans l'autre sens, l'API de création de checkout n'ayant
|
||||||
|
**aucun champ de métadonnées libre**, c'est la `description` du checkout
|
||||||
|
(« affichée dans les outils SumUp ») qui porte le nom du bénévole :
|
||||||
|
`Prénom Nom — libellé — édition AAAA` (`_checkout_description` ; donnée
|
||||||
|
transmise au prestataire de paiement, pas un log). Une fois promue,
|
||||||
la ligne `PendingFormSubmission` correspondante voit ses champs nominatifs
|
la ligne `PendingFormSubmission` correspondante voit ses champs nominatifs
|
||||||
vidés (RGPD : l'information ne vit plus que sur `Volunteer`) ; une ligne
|
vidés (RGPD : l'information ne vit plus que sur `Volunteer`) ; une ligne
|
||||||
jamais payée est purgée après 48 h sans être relancée
|
jamais payée est purgée après 48 h sans être relancée
|
||||||
@@ -724,7 +734,8 @@ l'étape 21.)*
|
|||||||
│ │ ├── main.py # instance FastAPI
|
│ │ ├── main.py # instance FastAPI
|
||||||
│ │ ├── config.py # settings (pydantic-settings)
|
│ │ ├── config.py # settings (pydantic-settings)
|
||||||
│ │ ├── db.py # engine + session
|
│ │ ├── db.py # engine + session
|
||||||
│ │ ├── logging_config.py # RGPD : pas de PII dans les logs
|
│ │ ├── logging_config.py # RGPD : pas de PII dans les logs ; identifiant de requête
|
||||||
|
│ ├── request_logging.py # une ligne de log par requête, X-Request-ID, erreurs 500
|
||||||
│ │ ├── api/ # routers + schemas Pydantic + deps
|
│ │ ├── api/ # routers + schemas Pydantic + deps
|
||||||
│ │ │ # health, auth, accounts, editions,
|
│ │ │ # health, auth, accounts, editions,
|
||||||
│ │ │ # day_parts, imports, artists, volunteers,
|
│ │ │ # day_parts, imports, artists, volunteers,
|
||||||
@@ -785,6 +796,23 @@ Aucune dépendance locale requise hors Docker.
|
|||||||
+ `locales/fr.json`. Frontend : fichier i18n dédié. Vrai même en monolingue.
|
+ `locales/fr.json`. Frontend : fichier i18n dédié. Vrai même en monolingue.
|
||||||
- **RGPD :**
|
- **RGPD :**
|
||||||
- Pas de log des noms, emails, téléphones. On logue des identifiants.
|
- Pas de log des noms, emails, téléphones. On logue des identifiants.
|
||||||
|
- **Logs** (`app/logging_config.py`, `app/request_logging.py`) : une ligne
|
||||||
|
par requête HTTP (`app.request` — méthode, route **modèle** + ses
|
||||||
|
identifiants, jamais l'URL brute ni un paramètre `*token*`, statut, durée,
|
||||||
|
code d'erreur métier `detail.code`, compte connecté) ; INFO / WARNING
|
||||||
|
(4xx) / ERROR (5xx), `/health` en DEBUG. Chaque requête a un identifiant
|
||||||
|
(`X-Request-ID`, repris s'il est fourni et bien formé) préfixé à toutes
|
||||||
|
ses lignes de log, tâches de fond comprises, et affiché à l'utilisateur
|
||||||
|
sous une erreur (`ErrorNote` : « Référence à communiquer ») — `grep` de
|
||||||
|
cette référence dans les logs du conteneur pour diagnostiquer. Erreur 422
|
||||||
|
de validation : emplacement + type, jamais la valeur saisie. Exception non
|
||||||
|
gérée : trace complète + `500 {detail: {code: "error.internal",
|
||||||
|
request_id}}`. Journal d'accès d'uvicorn **désactivé** (il écrivait la
|
||||||
|
chaîne de requête, donc le `access_token` du formulaire public). Logs
|
||||||
|
métier sur les parcours opaques : paiement SumUp (inscription en attente,
|
||||||
|
checkout créé/refusé avec le code d'erreur SumUp, changements de statut,
|
||||||
|
promotion, webhook), connexion (cause réelle d'un refus, côté logs
|
||||||
|
seulement), import, envoi de plannings, solveur (durées).
|
||||||
- `Edition.retention_days` : durée de rétention paramétrable.
|
- `Edition.retention_days` : durée de rétention paramétrable.
|
||||||
- Endpoint de suppression complète d'un bénévole : *hard delete* en cascade.
|
- Endpoint de suppression complète d'un bénévole : *hard delete* en cascade.
|
||||||
- **Dates et heures :** stockées naïves, interprétées en heure locale
|
- **Dates et heures :** stockées naïves, interprétées en heure locale
|
||||||
@@ -875,6 +903,7 @@ Aucune dépendance locale requise hors Docker.
|
|||||||
| 40 | Écran Statistiques (`/statistiques`, COORDINATION, `GET /editions/{id}/stats`) : inscriptions (nombre, évolution cumulée), réponses au formulaire, artistes demandés, cotisations (en ligne / sur place / aucune, par type, mécénat, paiements jamais aboutis), besoins par journée de festival face aux disponibilités déclarées et aux affectations, places pourvues et vœux par poste — graphiques SVG maison avec infobulles et vues tableau | à valider |
|
| 40 | Écran Statistiques (`/statistiques`, COORDINATION, `GET /editions/{id}/stats`) : inscriptions (nombre, évolution cumulée), réponses au formulaire, artistes demandés, cotisations (en ligne / sur place / aucune, par type, mécénat, paiements jamais aboutis), besoins par journée de festival face aux disponibilités déclarées et aux affectations, places pourvues et vœux par poste — graphiques SVG maison avec infobulles et vues tableau | à valider |
|
||||||
| 41 | Début de « journée de festival » indépendant de l'ordre des créneaux types : début du plus long creux qu'ils laissent sur 24 h (`validation.engine.day_boundary`, miroir `lib/planning.ts:dayBoundaryFromDayParts`). Avant, un créneau type d'ordre 0 déplacé à 21:00 faisait commencer chaque journée des grilles (besoins, vue bénévoles) à 21:00, jusqu'à 03:00 le lendemain | à valider |
|
| 41 | Début de « journée de festival » indépendant de l'ordre des créneaux types : début du plus long creux qu'ils laissent sur 24 h (`validation.engine.day_boundary`, miroir `lib/planning.ts:dayBoundaryFromDayParts`). Avant, un créneau type d'ordre 0 déplacé à 21:00 faisait commencer chaque journée des grilles (besoins, vue bénévoles) à 21:00, jusqu'à 03:00 le lendemain | à valider |
|
||||||
| 42 | Retrait des disponibilités déclarées (table `availability` supprimée, migration `0022` ; API, éditeur de la fiche bénévole, badge de couverture du panneau d'affectation, colonnes des statistiques) et des limites de temps dans la journée (règles `OUTSIDE_AVAILABILITY`, `DAILY_HOURS_EXCEEDED`, `INSUFFICIENT_REST`, malus de disponibilité et de repos du solveur, filtre « Surchargés » de la vue bénévoles) — seul le chevauchement reste contrôlé. La case « je suis disponible » du formulaire public est conservée | à valider |
|
| 42 | Retrait des disponibilités déclarées (table `availability` supprimée, migration `0022` ; API, éditeur de la fiche bénévole, badge de couverture du panneau d'affectation, colonnes des statistiques) et des limites de temps dans la journée (règles `OUTSIDE_AVAILABILITY`, `DAILY_HOURS_EXCEEDED`, `INSUFFICIENT_REST`, malus de disponibilité et de repos du solveur, filtre « Surchargés » de la vue bénévoles) — seul le chevauchement reste contrôlé. La case « je suis disponible » du formulaire public est conservée | à valider |
|
||||||
|
| 43 | Logs de diagnostic (ligne par requête avec route modèle, statut, durée, code d'erreur et compte ; identifiant `X-Request-ID` repris dans chaque log et affiché à l'utilisateur sous une erreur ; exceptions non gérées tracées ; journal d'accès d'uvicorn coupé car il exposait le jeton du formulaire public ; logs métier SumUp, connexion, import, plannings, solveur) et rapprochement SumUp ↔ bénévole : nom du bénévole dans la description du checkout (pas de métadonnées libres dans l'API), code et identifiant de transaction enregistrés au paiement (`Volunteer.fee_transaction_code`/`fee_transaction_id`, migration `0023`), affichés sur la fiche et dans l'export Excel | à 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 +
|
||||||
|
|||||||
@@ -84,7 +84,17 @@ def login(
|
|||||||
or account.password_hash is None
|
or account.password_hash is None
|
||||||
or not verify_password(payload.password, account.password_hash)
|
or not verify_password(payload.password, account.password_hash)
|
||||||
):
|
):
|
||||||
|
# Cause réelle dans les logs seulement — la réponse reste la même
|
||||||
|
# (pas de divulgation). Jamais l'email saisi.
|
||||||
|
if account is None:
|
||||||
|
reason = "aucun compte pour cet email"
|
||||||
|
elif account.password_hash is None:
|
||||||
|
reason = f"compte {account.id} sans mot de passe (lien magique)"
|
||||||
|
else:
|
||||||
|
reason = f"mot de passe incorrect pour le compte {account.id}"
|
||||||
|
logger.info("Connexion refusée (édition %s) : %s", edition_id, reason)
|
||||||
raise _invalid_credentials()
|
raise _invalid_credentials()
|
||||||
|
logger.info("Connexion par mot de passe : compte %s (édition %s)", account.id, edition_id)
|
||||||
return _issue(account)
|
return _issue(account)
|
||||||
|
|
||||||
|
|
||||||
@@ -135,7 +145,19 @@ def request_magic_link(
|
|||||||
tâche de fond (une erreur SMTP est journalisée, pas remontée)."""
|
tâche de fond (une erreur SMTP est journalisée, pas remontée)."""
|
||||||
magic_link_limiter.hit(f"{edition_id}:{payload.email.strip().lower()}")
|
magic_link_limiter.hit(f"{edition_id}:{payload.email.strip().lower()}")
|
||||||
account = _account_by_email(session, edition_id, payload.email)
|
account = _account_by_email(session, edition_id, payload.email)
|
||||||
|
if account is None:
|
||||||
|
logger.info(
|
||||||
|
"Lien magique demandé sans compte correspondant (édition %s) — rien envoyé "
|
||||||
|
"(un email sur une fiche bénévole ne crée pas d'accès)",
|
||||||
|
edition_id,
|
||||||
|
)
|
||||||
|
elif account.password_hash is not None:
|
||||||
|
logger.info(
|
||||||
|
"Lien magique demandé pour le compte %s, qui a un mot de passe — rien envoyé",
|
||||||
|
account.id,
|
||||||
|
)
|
||||||
if account is not None and account.password_hash is None:
|
if account is not None and account.password_hash is None:
|
||||||
|
logger.info("Lien magique émis pour le compte %s (édition %s)", account.id, edition_id)
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
raw = issue_magic_link_token(session, account)
|
raw = issue_magic_link_token(session, account)
|
||||||
session.commit()
|
session.commit()
|
||||||
@@ -172,13 +194,26 @@ def verify_magic_link(
|
|||||||
or token.used_at is not None
|
or token.used_at is not None
|
||||||
or token.expires_at < local_now()
|
or token.expires_at < local_now()
|
||||||
):
|
):
|
||||||
|
if token is None:
|
||||||
|
reason = "jeton inconnu"
|
||||||
|
elif token.used_at is not None:
|
||||||
|
reason = f"jeton déjà utilisé (compte {token.account_id})"
|
||||||
|
else:
|
||||||
|
reason = f"jeton expiré (compte {token.account_id})"
|
||||||
|
logger.info("Lien magique refusé (édition %s) : %s", edition_id, reason)
|
||||||
raise _invalid_token()
|
raise _invalid_token()
|
||||||
account = session.get(Account, token.account_id)
|
account = session.get(Account, token.account_id)
|
||||||
if account is None or account.edition_id != edition_id:
|
if account is None or account.edition_id != edition_id:
|
||||||
|
logger.info(
|
||||||
|
"Lien magique refusé (édition %s) : compte %s absent ou d'une autre édition",
|
||||||
|
edition_id,
|
||||||
|
token.account_id,
|
||||||
|
)
|
||||||
raise _invalid_token()
|
raise _invalid_token()
|
||||||
token.used_at = local_now()
|
token.used_at = local_now()
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(account)
|
session.refresh(account)
|
||||||
|
logger.info("Connexion par lien magique : compte %s (édition %s)", account.id, edition_id)
|
||||||
return _issue(account)
|
return _issue(account)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+113
-3
@@ -718,6 +718,11 @@ async def submit_public_form(
|
|||||||
set_artists(session, volunteer, artist_ids)
|
set_artists(session, volunteer, artist_ids)
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(volunteer)
|
session.refresh(volunteer)
|
||||||
|
logger.info(
|
||||||
|
"Formulaire public : bénévole %s créé (édition %s, sans paiement en ligne)",
|
||||||
|
volunteer.id,
|
||||||
|
edition.id,
|
||||||
|
)
|
||||||
if volunteer.email:
|
if volunteer.email:
|
||||||
subject, body = render_registration_confirmation(first_name=volunteer.first_name)
|
subject, body = render_registration_confirmation(first_name=volunteer.first_name)
|
||||||
background.add_task(
|
background.add_task(
|
||||||
@@ -758,6 +763,14 @@ async def submit_public_form(
|
|||||||
session.add(pending)
|
session.add(pending)
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(pending)
|
session.refresh(pending)
|
||||||
|
logger.info(
|
||||||
|
"Formulaire public : inscription en attente de paiement %s (édition %s, "
|
||||||
|
"montant %.2f, mécénat=%s)",
|
||||||
|
pending.id,
|
||||||
|
edition.id,
|
||||||
|
pending.payment_amount,
|
||||||
|
pending.requires_mecenat_info,
|
||||||
|
)
|
||||||
|
|
||||||
if pending.requires_mecenat_info:
|
if pending.requires_mecenat_info:
|
||||||
return FormSubmissionResult(pending_token=pending.id, requires_mecenat_info=True)
|
return FormSubmissionResult(pending_token=pending.id, requires_mecenat_info=True)
|
||||||
@@ -783,19 +796,53 @@ def _create_pending_checkout(
|
|||||||
amount=pending.payment_amount,
|
amount=pending.payment_amount,
|
||||||
currency="EUR",
|
currency="EUR",
|
||||||
checkout_reference=f"{pending.id}-{uuid.uuid4().hex[:8]}",
|
checkout_reference=f"{pending.id}-{uuid.uuid4().hex[:8]}",
|
||||||
description=f"{pending.payment_label} — édition {edition.year}",
|
description=_checkout_description(edition, pending),
|
||||||
return_url=f"{base}/api/editions/{edition.id}/form/public/sumup-webhook",
|
return_url=f"{base}/api/editions/{edition.id}/form/public/sumup-webhook",
|
||||||
redirect_url=f"{base}/inscription/{template.access_token}?paiement={pending.id}",
|
redirect_url=f"{base}/inscription/{template.access_token}?paiement={pending.id}",
|
||||||
)
|
)
|
||||||
if checkout is None:
|
if checkout is None:
|
||||||
|
logger.warning(
|
||||||
|
"Checkout SumUp non créé pour l'inscription en attente %s (édition %s) — "
|
||||||
|
"le bénévole devra réessayer",
|
||||||
|
pending.id,
|
||||||
|
edition.id,
|
||||||
|
)
|
||||||
return None
|
return None
|
||||||
pending.checkout_id = checkout.id
|
pending.checkout_id = checkout.id
|
||||||
pending.checkout_status = FeePaymentStatus.PENDING
|
pending.checkout_status = FeePaymentStatus.PENDING
|
||||||
session.add(pending)
|
session.add(pending)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
logger.info(
|
||||||
|
"Checkout SumUp %s créé pour l'inscription en attente %s (édition %s)",
|
||||||
|
checkout.id,
|
||||||
|
pending.id,
|
||||||
|
edition.id,
|
||||||
|
)
|
||||||
|
if not checkout.hosted_checkout_url:
|
||||||
|
logger.warning(
|
||||||
|
"Checkout SumUp %s sans hosted_checkout_url — le paiement hébergé "
|
||||||
|
"est-il activé sur le compte marchand ?",
|
||||||
|
checkout.id,
|
||||||
|
)
|
||||||
return checkout.hosted_checkout_url
|
return checkout.hosted_checkout_url
|
||||||
|
|
||||||
|
|
||||||
|
# Longueur prudente : la doc SumUp ne fixe pas de maximum pour `description`.
|
||||||
|
_CHECKOUT_DESCRIPTION_MAX = 255
|
||||||
|
|
||||||
|
|
||||||
|
def _checkout_description(edition: Edition, pending: PendingFormSubmission) -> str:
|
||||||
|
"""Libellé de la transaction côté SumUp (tableau de bord, exports) : le
|
||||||
|
nom du bénévole en tête, pour qu'un paiement s'y rapproche d'un coup
|
||||||
|
d'œil de la bonne personne — l'API de création de checkout n'a pas de
|
||||||
|
champ de métadonnées libre (voir `app/sumup/client.py:create_checkout`).
|
||||||
|
Donnée transmise au prestataire de paiement, pas un log : la règle RGPD
|
||||||
|
de `app/logging_config.py` ne s'applique pas ici."""
|
||||||
|
name = " ".join(p for p in (pending.first_name, pending.last_name) if p)
|
||||||
|
parts = [name, pending.payment_label, f"édition {edition.year}"]
|
||||||
|
return " — ".join(p for p in parts if p)[:_CHECKOUT_DESCRIPTION_MAX]
|
||||||
|
|
||||||
|
|
||||||
def _promote_pending(
|
def _promote_pending(
|
||||||
session: Session,
|
session: Session,
|
||||||
edition: Edition,
|
edition: Edition,
|
||||||
@@ -824,6 +871,8 @@ def _promote_pending(
|
|||||||
fee_checkout_id=checkout.id,
|
fee_checkout_id=checkout.id,
|
||||||
fee_payment_status=FeePaymentStatus.PAID,
|
fee_payment_status=FeePaymentStatus.PAID,
|
||||||
fee_paid_at=local_now(),
|
fee_paid_at=local_now(),
|
||||||
|
fee_transaction_code=checkout.transaction_code,
|
||||||
|
fee_transaction_id=checkout.transaction_id,
|
||||||
mecenat_address=pending.mecenat_address,
|
mecenat_address=pending.mecenat_address,
|
||||||
mecenat_city=pending.mecenat_city,
|
mecenat_city=pending.mecenat_city,
|
||||||
mecenat_postal_code=pending.mecenat_postal_code,
|
mecenat_postal_code=pending.mecenat_postal_code,
|
||||||
@@ -866,6 +915,22 @@ def _promote_pending(
|
|||||||
pending.mecenat_country = None
|
pending.mecenat_country = None
|
||||||
session.add(pending)
|
session.add(pending)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
logger.info(
|
||||||
|
"Paiement confirmé : inscription en attente %s → bénévole %s (édition %s, "
|
||||||
|
"checkout %s, transaction %s, reçu mécénat %s)",
|
||||||
|
pending.id,
|
||||||
|
volunteer.id,
|
||||||
|
edition.id,
|
||||||
|
checkout.id,
|
||||||
|
checkout.transaction_code or "inconnue",
|
||||||
|
volunteer.mecenat_receipt_number or "-",
|
||||||
|
)
|
||||||
|
if not checkout.transaction_code:
|
||||||
|
logger.warning(
|
||||||
|
"Checkout SumUp %s payé sans code de transaction renvoyé — rapprochement "
|
||||||
|
"à faire depuis le tableau de bord SumUp",
|
||||||
|
checkout.id,
|
||||||
|
)
|
||||||
|
|
||||||
if volunteer.email:
|
if volunteer.email:
|
||||||
# Paiement « mécénat » (adresse postale saisie) : le cerfa rempli part
|
# Paiement « mécénat » (adresse postale saisie) : le cerfa rempli part
|
||||||
@@ -906,7 +971,10 @@ def _send_paid_confirmation(
|
|||||||
pdf = generate_mecenat_receipts_pdf([receipt])
|
pdf = generate_mecenat_receipts_pdf([receipt])
|
||||||
attachments.append((pdf, "application/pdf", RECEIPT_FILENAME))
|
attachments.append((pdf, "application/pdf", RECEIPT_FILENAME))
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Reçu fiscal mécénat non généré — email envoyé sans pièce jointe")
|
logger.exception(
|
||||||
|
"Reçu fiscal mécénat n° %s non généré — email envoyé sans pièce jointe",
|
||||||
|
receipt.number or "?",
|
||||||
|
)
|
||||||
send(to, subject, body, logo=logo, attachments=attachments)
|
send(to, subject, body, logo=logo, attachments=attachments)
|
||||||
|
|
||||||
|
|
||||||
@@ -925,10 +993,21 @@ def _apply_pending_checkout_result(
|
|||||||
try:
|
try:
|
||||||
new_status = FeePaymentStatus(checkout.status)
|
new_status = FeePaymentStatus(checkout.status)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
|
logger.warning(
|
||||||
|
"Checkout SumUp %s : statut inconnu %r ignoré", checkout.id, checkout.status
|
||||||
|
)
|
||||||
return
|
return
|
||||||
if new_status == FeePaymentStatus.PAID:
|
if new_status == FeePaymentStatus.PAID:
|
||||||
_promote_pending(session, edition, pending, checkout, background)
|
_promote_pending(session, edition, pending, checkout, background)
|
||||||
else:
|
else:
|
||||||
|
if new_status != pending.checkout_status:
|
||||||
|
logger.info(
|
||||||
|
"Checkout SumUp %s (inscription en attente %s) : %s → %s",
|
||||||
|
checkout.id,
|
||||||
|
pending.id,
|
||||||
|
pending.checkout_status,
|
||||||
|
new_status,
|
||||||
|
)
|
||||||
pending.checkout_status = new_status
|
pending.checkout_status = new_status
|
||||||
session.add(pending)
|
session.add(pending)
|
||||||
session.commit()
|
session.commit()
|
||||||
@@ -1043,6 +1122,12 @@ def retry_pending_payment(
|
|||||||
)
|
)
|
||||||
|
|
||||||
template = _get_or_create_template(session, edition.id)
|
template = _get_or_create_template(session, edition.id)
|
||||||
|
logger.info(
|
||||||
|
"Relance du paiement de l'inscription en attente %s (édition %s, statut %s)",
|
||||||
|
pending.id,
|
||||||
|
edition.id,
|
||||||
|
pending.checkout_status,
|
||||||
|
)
|
||||||
checkout_url = _create_pending_checkout(session, edition, template, pending)
|
checkout_url = _create_pending_checkout(session, edition, template, pending)
|
||||||
return FormSubmissionResult(pending_token=pending.id, sumup_checkout_url=checkout_url)
|
return FormSubmissionResult(pending_token=pending.id, sumup_checkout_url=checkout_url)
|
||||||
|
|
||||||
@@ -1059,13 +1144,23 @@ def sumup_webhook(
|
|||||||
identifiant : jamais fait confiance sans relire l'état réel via l'API
|
identifiant : jamais fait confiance sans relire l'état réel via l'API
|
||||||
(voir `app/sumup/client.py:get_checkout`). Répond toujours `200`, y
|
(voir `app/sumup/client.py:get_checkout`). Répond toujours `200`, y
|
||||||
compris sur un événement inconnu — SumUp réessaie sinon inutilement."""
|
compris sur un événement inconnu — SumUp réessaie sinon inutilement."""
|
||||||
|
logger.info(
|
||||||
|
"Webhook SumUp reçu (édition %s, événement %s, checkout %s)",
|
||||||
|
edition.id,
|
||||||
|
payload.event_type,
|
||||||
|
payload.id,
|
||||||
|
)
|
||||||
template = _get_or_create_template(session, edition.id)
|
template = _get_or_create_template(session, edition.id)
|
||||||
if not template.sumup_api_key:
|
if not template.sumup_api_key:
|
||||||
|
logger.warning(
|
||||||
|
"Webhook SumUp ignoré : aucune clé API SumUp configurée (édition %s)",
|
||||||
|
edition.id,
|
||||||
|
)
|
||||||
return Response(status_code=status.HTTP_200_OK)
|
return Response(status_code=status.HTTP_200_OK)
|
||||||
|
|
||||||
checkout = get_checkout(api_key=template.sumup_api_key, checkout_id=payload.id)
|
checkout = get_checkout(api_key=template.sumup_api_key, checkout_id=payload.id)
|
||||||
if checkout is None:
|
if checkout is None:
|
||||||
logger.warning("Webhook SumUp : relecture du checkout impossible.")
|
logger.warning("Webhook SumUp : relecture du checkout %s impossible.", payload.id)
|
||||||
return Response(status_code=status.HTTP_200_OK)
|
return Response(status_code=status.HTTP_200_OK)
|
||||||
|
|
||||||
pending = session.exec(
|
pending = session.exec(
|
||||||
@@ -1094,6 +1189,21 @@ def sumup_webhook(
|
|||||||
volunteer.fee_payment_status = new_status
|
volunteer.fee_payment_status = new_status
|
||||||
if new_status == FeePaymentStatus.PAID and volunteer.fee_paid_at is None:
|
if new_status == FeePaymentStatus.PAID and volunteer.fee_paid_at is None:
|
||||||
volunteer.fee_paid_at = local_now()
|
volunteer.fee_paid_at = local_now()
|
||||||
|
if checkout.transaction_code and not volunteer.fee_transaction_code:
|
||||||
|
volunteer.fee_transaction_code = checkout.transaction_code
|
||||||
|
volunteer.fee_transaction_id = checkout.transaction_id
|
||||||
session.add(volunteer)
|
session.add(volunteer)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
logger.info(
|
||||||
|
"Webhook SumUp : bénévole %s (checkout %s) → %s",
|
||||||
|
volunteer.id,
|
||||||
|
payload.id,
|
||||||
|
new_status,
|
||||||
|
)
|
||||||
|
return Response(status_code=status.HTTP_200_OK)
|
||||||
|
logger.warning(
|
||||||
|
"Webhook SumUp : checkout %s inconnu dans l'édition %s (inscription purgée ?)",
|
||||||
|
payload.id,
|
||||||
|
edition.id,
|
||||||
|
)
|
||||||
return Response(status_code=status.HTTP_200_OK)
|
return Response(status_code=status.HTTP_200_OK)
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
"""Endpoints d'import : analyse puis exécution."""
|
"""Endpoints d'import : analyse puis exécution."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
from sqlmodel import Session
|
from sqlmodel import Session
|
||||||
@@ -17,6 +19,8 @@ from app.importing.schemas import (
|
|||||||
from app.importing.service import run_import, suggest_mapping, validate_mapping
|
from app.importing.service import run_import, suggest_mapping, validate_mapping
|
||||||
from app.models import Account, Edition
|
from app.models import Account, Edition
|
||||||
|
|
||||||
|
logger = logging.getLogger("app.importing")
|
||||||
|
|
||||||
router = APIRouter(prefix="/editions/{edition_id}/import", tags=["import"])
|
router = APIRouter(prefix="/editions/{edition_id}/import", tags=["import"])
|
||||||
|
|
||||||
_PREVIEW_ROWS = 5
|
_PREVIEW_ROWS = 5
|
||||||
@@ -107,9 +111,19 @@ async def execute(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
return run_import(
|
report = run_import(
|
||||||
session=session,
|
session=session,
|
||||||
edition=edition,
|
edition=edition,
|
||||||
table=table,
|
table=table,
|
||||||
mapping=parsed_mapping,
|
mapping=parsed_mapping,
|
||||||
)
|
)
|
||||||
|
logger.info(
|
||||||
|
"Import %s (édition %s) : %d lignes — %d créés, %d mis à jour, %d ignorés",
|
||||||
|
"simulé" if report.dry_run else "exécuté",
|
||||||
|
edition.id,
|
||||||
|
report.total_rows,
|
||||||
|
report.created,
|
||||||
|
report.updated,
|
||||||
|
report.skipped,
|
||||||
|
)
|
||||||
|
return report
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Envoi d'emails d'information aux bénévoles (récapitulatif de planning)."""
|
"""Envoi d'emails d'information aux bénévoles (récapitulatif de planning)."""
|
||||||
|
|
||||||
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
|
|
||||||
@@ -18,6 +19,8 @@ from app.email import edition_logo, render_planning, send_many
|
|||||||
from app.i18n import t
|
from app.i18n import t
|
||||||
from app.models import Account, Assignment, Edition, Position, Shift, Volunteer
|
from app.models import Account, Assignment, Edition, Position, Shift, Volunteer
|
||||||
|
|
||||||
|
logger = logging.getLogger("app.notifications")
|
||||||
|
|
||||||
router = APIRouter(
|
router = APIRouter(
|
||||||
prefix="/editions/{edition_id}/notifications", tags=["notifications"]
|
prefix="/editions/{edition_id}/notifications", tags=["notifications"]
|
||||||
)
|
)
|
||||||
@@ -112,6 +115,12 @@ def send_planning_notification(
|
|||||||
recipients.append(volunteer.id)
|
recipients.append(volunteer.id)
|
||||||
|
|
||||||
session.commit() # persiste les éventuels MagicLinkToken émis
|
session.commit() # persiste les éventuels MagicLinkToken émis
|
||||||
|
logger.info(
|
||||||
|
"Récapitulatif de planning (édition %s) : %d emails en file, %d bénévoles sans email",
|
||||||
|
edition.id,
|
||||||
|
len(items),
|
||||||
|
skipped_no_email,
|
||||||
|
)
|
||||||
background.add_task(send_many, items, logo=edition_logo(session, edition.id))
|
background.add_task(send_many, items, logo=edition_logo(session, edition.id))
|
||||||
return PlanningNotificationResult(
|
return PlanningNotificationResult(
|
||||||
queued=len(items),
|
queued=len(items),
|
||||||
|
|||||||
@@ -102,6 +102,10 @@ class VolunteerRead(ApiModel):
|
|||||||
fee_amount: float | None
|
fee_amount: float | None
|
||||||
fee_payment_status: FeePaymentStatus | None
|
fee_payment_status: FeePaymentStatus | None
|
||||||
fee_paid_at: dt.datetime | None
|
fee_paid_at: dt.datetime | None
|
||||||
|
# Transaction SumUp ayant réglé la cotisation (code du tableau de bord
|
||||||
|
# SumUp + identifiant technique) — `None` tant que non payée.
|
||||||
|
fee_transaction_code: str | None
|
||||||
|
fee_transaction_id: str | None
|
||||||
# Coordonnées pour un reçu fiscal (cerfa) — seulement si payé via un
|
# Coordonnées pour un reçu fiscal (cerfa) — seulement si payé via un
|
||||||
# type de paiement « mécénat » (voir `FormPaymentOption.requires_mecenat_info`).
|
# type de paiement « mécénat » (voir `FormPaymentOption.requires_mecenat_info`).
|
||||||
mecenat_address: str | None
|
mecenat_address: str | None
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
coordination affecte elle-même, dans l'écran habituel, en tenant compte des
|
coordination affecte elle-même, dans l'écran habituel, en tenant compte des
|
||||||
bénévoles remontés en tête de liste."""
|
bénévoles remontés en tête de liste."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
@@ -14,6 +16,8 @@ from app.db import get_session
|
|||||||
from app.models import Account, Edition
|
from app.models import Account, Edition
|
||||||
from app.solver import build_solver_input, solve
|
from app.solver import build_solver_input, solve
|
||||||
|
|
||||||
|
logger = logging.getLogger("app.solver")
|
||||||
|
|
||||||
router = APIRouter(prefix="/editions/{edition_id}/solver", tags=["solver"])
|
router = APIRouter(prefix="/editions/{edition_id}/solver", tags=["solver"])
|
||||||
|
|
||||||
|
|
||||||
@@ -23,8 +27,20 @@ def run_solver(
|
|||||||
_guard: Account = Depends(require_staff),
|
_guard: Account = Depends(require_staff),
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
) -> SolverRunResult:
|
) -> SolverRunResult:
|
||||||
|
started = time.monotonic()
|
||||||
data = build_solver_input(session=session, edition=edition)
|
data = build_solver_input(session=session, edition=edition)
|
||||||
|
loaded = time.monotonic()
|
||||||
solution = solve(data)
|
solution = solve(data)
|
||||||
|
logger.info(
|
||||||
|
"Solveur (édition %s) : %d créneaux, %d bénévoles, %d suggestions — "
|
||||||
|
"chargement %d ms, résolution %d ms",
|
||||||
|
edition.id,
|
||||||
|
len(data.shifts),
|
||||||
|
len(data.volunteers),
|
||||||
|
len(solution.suggestions),
|
||||||
|
(loaded - started) * 1000,
|
||||||
|
(time.monotonic() - loaded) * 1000,
|
||||||
|
)
|
||||||
return SolverRunResult(
|
return SolverRunResult(
|
||||||
suggestions=[
|
suggestions=[
|
||||||
SolverSuggestion(
|
SolverSuggestion(
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import uuid
|
|||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
|
|
||||||
import jwt
|
import jwt
|
||||||
from fastapi import Depends, HTTPException, status
|
from fastapi import Depends, HTTPException, Request, status
|
||||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
from sqlmodel import Session
|
from sqlmodel import Session
|
||||||
|
|
||||||
@@ -31,6 +31,7 @@ def _forbidden() -> HTTPException:
|
|||||||
|
|
||||||
|
|
||||||
def get_current_account(
|
def get_current_account(
|
||||||
|
request: Request,
|
||||||
credentials: HTTPAuthorizationCredentials | None = Depends(_bearer),
|
credentials: HTTPAuthorizationCredentials | None = Depends(_bearer),
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
) -> Account:
|
) -> Account:
|
||||||
@@ -44,6 +45,9 @@ def get_current_account(
|
|||||||
account = session.get(Account, account_id)
|
account = session.get(Account, account_id)
|
||||||
if account is None:
|
if account is None:
|
||||||
raise _unauthorized()
|
raise _unauthorized()
|
||||||
|
# Repris sur la ligne d'accès de la requête (`app/request_logging.py`) :
|
||||||
|
# qui a rencontré l'erreur, par identifiant seulement.
|
||||||
|
request.state.account_id = account.id
|
||||||
return account
|
return account
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ _HEADER = [
|
|||||||
"Montant (€)",
|
"Montant (€)",
|
||||||
"Statut du paiement",
|
"Statut du paiement",
|
||||||
"Payée le",
|
"Payée le",
|
||||||
|
"Transaction SumUp (code)",
|
||||||
|
"Transaction SumUp (identifiant)",
|
||||||
"Mécénat — adresse",
|
"Mécénat — adresse",
|
||||||
"Mécénat — ville",
|
"Mécénat — ville",
|
||||||
"Mécénat — code postal",
|
"Mécénat — code postal",
|
||||||
@@ -114,6 +116,8 @@ def generate_volunteers_xlsx(
|
|||||||
v.fee_amount if v.fee_amount is not None else "",
|
v.fee_amount if v.fee_amount is not None else "",
|
||||||
_FEE_STATUS_LABELS.get(v.fee_payment_status, ""),
|
_FEE_STATUS_LABELS.get(v.fee_payment_status, ""),
|
||||||
_fmt_dt(v.fee_paid_at),
|
_fmt_dt(v.fee_paid_at),
|
||||||
|
_safe_text(v.fee_transaction_code or ""),
|
||||||
|
_safe_text(v.fee_transaction_id or ""),
|
||||||
_safe_text(v.mecenat_address or ""),
|
_safe_text(v.mecenat_address or ""),
|
||||||
_safe_text(v.mecenat_city or ""),
|
_safe_text(v.mecenat_city or ""),
|
||||||
_safe_text(v.mecenat_postal_code or ""),
|
_safe_text(v.mecenat_postal_code or ""),
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import logging
|
import logging
|
||||||
|
from contextvars import ContextVar
|
||||||
|
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
|
|
||||||
@@ -7,6 +8,19 @@ from app.config import get_settings
|
|||||||
# téléphone dans les logs. On journalise des identifiants (UUID/entier) et des
|
# téléphone dans les logs. On journalise des identifiants (UUID/entier) et des
|
||||||
# codes. Toute nouvelle instruction de log doit respecter cette règle.
|
# codes. Toute nouvelle instruction de log doit respecter cette règle.
|
||||||
|
|
||||||
|
# Identifiant de la requête HTTP en cours (voir `app/request_logging.py`),
|
||||||
|
# repris sur chaque ligne de log émise pendant son traitement — y compris
|
||||||
|
# dans ses tâches de fond (`BackgroundTasks`, même contexte) — pour retrouver
|
||||||
|
# d'un `grep` tout ce qui s'est passé lors de l'erreur qu'un utilisateur
|
||||||
|
# signale (la référence lui est affichée, voir `frontend/.../state.tsx`).
|
||||||
|
request_id_var: ContextVar[str] = ContextVar("request_id", default="-")
|
||||||
|
|
||||||
|
|
||||||
|
class RequestIdFilter(logging.Filter):
|
||||||
|
def filter(self, record: logging.LogRecord) -> bool:
|
||||||
|
record.request_id = request_id_var.get()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def configure_logging() -> None:
|
def configure_logging() -> None:
|
||||||
# `force=True` : on réinstalle le handler racine même si l'import de
|
# `force=True` : on réinstalle le handler racine même si l'import de
|
||||||
@@ -14,6 +28,17 @@ def configure_logging() -> None:
|
|||||||
# sinon les logs `app.*` n'apparaissent pas dans la sortie du conteneur.
|
# sinon les logs `app.*` n'apparaissent pas dans la sortie du conteneur.
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=get_settings().log_level,
|
level=get_settings().log_level,
|
||||||
format="%(asctime)s %(levelname)s %(name)s %(message)s",
|
format="%(asctime)s %(levelname)s [%(request_id)s] %(name)s %(message)s",
|
||||||
force=True,
|
force=True,
|
||||||
)
|
)
|
||||||
|
# Filtre posé sur le handler (pas sur un logger) : il voit aussi les
|
||||||
|
# lignes des bibliothèques (uvicorn, sqlalchemy…) propagées à la racine.
|
||||||
|
for handler in logging.getLogger().handlers:
|
||||||
|
handler.addFilter(RequestIdFilter())
|
||||||
|
# Journal d'accès d'uvicorn remplacé par `app/request_logging.py` : le
|
||||||
|
# sien écrit l'URL complète, chaîne de requête comprise — donc le jeton
|
||||||
|
# `access_token` du formulaire public (CLAUDE.md § 2, `FormTemplate`).
|
||||||
|
logging.getLogger("uvicorn.access").disabled = True
|
||||||
|
# Bavard au niveau INFO (une ligne par appel SumUp/hCaptcha), sans
|
||||||
|
# intérêt à côté des logs de l'appli.
|
||||||
|
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ from app.api.stats import router as stats_router
|
|||||||
from app.api.volunteers import router as volunteer_router
|
from app.api.volunteers import router as volunteer_router
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
from app.logging_config import configure_logging
|
from app.logging_config import configure_logging
|
||||||
|
from app.request_logging import REQUEST_ID_HEADER, install_request_logging
|
||||||
|
|
||||||
configure_logging()
|
configure_logging()
|
||||||
|
|
||||||
@@ -56,7 +57,9 @@ app.add_middleware(
|
|||||||
allow_credentials=True,
|
allow_credentials=True,
|
||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
|
expose_headers=[REQUEST_ID_HEADER],
|
||||||
)
|
)
|
||||||
|
install_request_logging(app)
|
||||||
|
|
||||||
app.include_router(health_router)
|
app.include_router(health_router)
|
||||||
app.include_router(auth_router)
|
app.include_router(auth_router)
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""Transaction SumUp de la cotisation
|
||||||
|
|
||||||
|
Revision ID: 0023_fee_transaction
|
||||||
|
Revises: 0022_drop_availability
|
||||||
|
Create Date: 2026-10-12
|
||||||
|
|
||||||
|
`volunteer.fee_transaction_code` (code affiché dans le tableau de bord SumUp,
|
||||||
|
ex. `TEENSK4W2K`) et `fee_transaction_id` (identifiant technique) : la
|
||||||
|
transaction qui a réglé la cotisation, posée à la confirmation du paiement —
|
||||||
|
voir `app/api/forms.py:_promote_pending`. Les bénévoles déjà payés restent
|
||||||
|
à `NULL`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0023_fee_transaction"
|
||||||
|
down_revision: str | None = "0022_drop_availability"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"volunteer", sa.Column("fee_transaction_code", sa.String(), nullable=True)
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_volunteer_fee_transaction_code", "volunteer", ["fee_transaction_code"]
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"volunteer", sa.Column("fee_transaction_id", sa.String(), nullable=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("volunteer", "fee_transaction_id")
|
||||||
|
op.drop_index("ix_volunteer_fee_transaction_code", table_name="volunteer")
|
||||||
|
op.drop_column("volunteer", "fee_transaction_code")
|
||||||
@@ -211,6 +211,13 @@ class Volunteer(TimestampMixin, table=True):
|
|||||||
sa_column=Column(SAEnum(FeePaymentStatus, native_enum=False), nullable=True),
|
sa_column=Column(SAEnum(FeePaymentStatus, native_enum=False), nullable=True),
|
||||||
)
|
)
|
||||||
fee_paid_at: dt.datetime | None = None
|
fee_paid_at: dt.datetime | None = None
|
||||||
|
# Transaction SumUp qui a réglé la cotisation (`app/sumup/client.py:
|
||||||
|
# SumUpCheckout`), posée à la confirmation du paiement : le code court
|
||||||
|
# (`TEENSK4W2K`) est celui du tableau de bord SumUp et de ses reçus —
|
||||||
|
# rapprochement comptable paiement ↔ bénévole. Exposés (fiche, export
|
||||||
|
# Excel), contrairement à `fee_checkout_id`.
|
||||||
|
fee_transaction_code: str | None = Field(default=None, index=True)
|
||||||
|
fee_transaction_id: str | None = None
|
||||||
# Coordonnées collectées uniquement pour un type de paiement « mécénat »
|
# Coordonnées collectées uniquement pour un type de paiement « mécénat »
|
||||||
# (`FormPaymentOption.requires_mecenat_info`), avant le paiement (voir
|
# (`FormPaymentOption.requires_mecenat_info`), avant le paiement (voir
|
||||||
# `PendingFormSubmission` et `app/api/forms.py:submit_mecenat_info`) —
|
# `PendingFormSubmission` et `app/api/forms.py:submit_mecenat_info`) —
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
"""Journal des requêtes HTTP — une ligne par requête, pour diagnostiquer
|
||||||
|
après coup une erreur rencontrée par un utilisateur (voir CLAUDE.md § 8,
|
||||||
|
RGPD : identifiants et codes seulement).
|
||||||
|
|
||||||
|
- Chaque requête reçoit un identifiant (`X-Request-ID`, repris s'il est
|
||||||
|
fourni et bien formé, généré sinon), renvoyé dans l'en-tête de la réponse
|
||||||
|
et préfixé à toutes les lignes de log émises pendant son traitement
|
||||||
|
(`app/logging_config.py:request_id_var`).
|
||||||
|
- La ligne d'accès donne la route **modèle** (`/editions/{edition_id}/…`) et
|
||||||
|
ses paramètres d'identifiants, jamais l'URL brute : la chaîne de requête
|
||||||
|
peut porter un jeton (`access_token` du formulaire public), et les
|
||||||
|
paramètres de chemin nommés `*token*` (inscription en attente) sont omis.
|
||||||
|
- Code d'erreur métier (`detail.code`) et compte connecté ajoutés quand ils
|
||||||
|
existent ; détails d'une erreur de validation 422 (emplacement + type,
|
||||||
|
jamais la valeur saisie).
|
||||||
|
- Une exception non gérée est journalisée avec sa trace et renvoie un 500
|
||||||
|
JSON `{detail: {code: "error.internal", request_id}}` plutôt qu'un texte
|
||||||
|
brut.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import FastAPI, HTTPException, Request
|
||||||
|
from fastapi.exception_handlers import (
|
||||||
|
http_exception_handler,
|
||||||
|
request_validation_exception_handler,
|
||||||
|
)
|
||||||
|
from fastapi.exceptions import RequestValidationError
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||||
|
|
||||||
|
from app.logging_config import request_id_var
|
||||||
|
|
||||||
|
logger = logging.getLogger("app.request")
|
||||||
|
|
||||||
|
REQUEST_ID_HEADER = "X-Request-ID"
|
||||||
|
_INCOMING_ID = re.compile(r"^[A-Za-z0-9-]{8,64}$")
|
||||||
|
# Sondes de santé (Docker, Caddy) : pas une ligne INFO toutes les 10 s.
|
||||||
|
_QUIET_PATHS = {"/health"}
|
||||||
|
|
||||||
|
|
||||||
|
def _route_label(request: Request) -> str:
|
||||||
|
route = request.scope.get("route")
|
||||||
|
template = getattr(route, "path", None) or "<non routé>"
|
||||||
|
params = {
|
||||||
|
key: value
|
||||||
|
for key, value in (request.scope.get("path_params") or {}).items()
|
||||||
|
if "token" not in key
|
||||||
|
}
|
||||||
|
if not params:
|
||||||
|
return template
|
||||||
|
return f"{template} " + " ".join(f"{k}={v}" for k, v in params.items())
|
||||||
|
|
||||||
|
|
||||||
|
def install_request_logging(app: FastAPI) -> None:
|
||||||
|
@app.middleware("http")
|
||||||
|
async def log_requests(request: Request, call_next):
|
||||||
|
incoming = request.headers.get(REQUEST_ID_HEADER, "")
|
||||||
|
request_id = incoming if _INCOMING_ID.match(incoming) else uuid.uuid4().hex[:12]
|
||||||
|
reset = request_id_var.set(request_id)
|
||||||
|
started = time.monotonic()
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
response = await call_next(request)
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"Exception non gérée sur %s %s", request.method, _route_label(request)
|
||||||
|
)
|
||||||
|
response = JSONResponse(
|
||||||
|
status_code=500,
|
||||||
|
content={"detail": {"code": "error.internal", "request_id": request_id}},
|
||||||
|
)
|
||||||
|
duration_ms = (time.monotonic() - started) * 1000
|
||||||
|
response.headers[REQUEST_ID_HEADER] = request_id
|
||||||
|
|
||||||
|
status_code = response.status_code
|
||||||
|
if status_code >= 500:
|
||||||
|
level = logging.ERROR
|
||||||
|
elif status_code >= 400:
|
||||||
|
level = logging.WARNING
|
||||||
|
elif request.url.path in _QUIET_PATHS or request.method == "OPTIONS":
|
||||||
|
level = logging.DEBUG
|
||||||
|
else:
|
||||||
|
level = logging.INFO
|
||||||
|
extras = []
|
||||||
|
error_code = getattr(request.state, "error_code", None)
|
||||||
|
if error_code:
|
||||||
|
extras.append(f"code={error_code}")
|
||||||
|
account_id = getattr(request.state, "account_id", None)
|
||||||
|
if account_id:
|
||||||
|
extras.append(f"compte={account_id}")
|
||||||
|
logger.log(
|
||||||
|
level,
|
||||||
|
"%s %s → %d (%d ms)%s",
|
||||||
|
request.method,
|
||||||
|
_route_label(request),
|
||||||
|
status_code,
|
||||||
|
duration_ms,
|
||||||
|
(" " + " ".join(extras)) if extras else "",
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
finally:
|
||||||
|
request_id_var.reset(reset)
|
||||||
|
|
||||||
|
@app.exception_handler(StarletteHTTPException)
|
||||||
|
async def log_http_exception(request: Request, exc: HTTPException):
|
||||||
|
detail = exc.detail
|
||||||
|
if isinstance(detail, dict) and detail.get("code"):
|
||||||
|
request.state.error_code = detail["code"]
|
||||||
|
violations = detail.get("violations")
|
||||||
|
if violations:
|
||||||
|
# Codes de violation seulement (`SHIFT_OVERLAP`…) : les
|
||||||
|
# paramètres peuvent porter des horaires, pas utiles ici.
|
||||||
|
request.state.error_code += " violations=" + ",".join(
|
||||||
|
sorted({str(v.get("code")) for v in violations if isinstance(v, dict)})
|
||||||
|
)
|
||||||
|
return await http_exception_handler(request, exc)
|
||||||
|
|
||||||
|
@app.exception_handler(RequestValidationError)
|
||||||
|
async def log_validation_error(request: Request, exc: RequestValidationError):
|
||||||
|
# Emplacement + type d'erreur, jamais `input` (la valeur saisie, qui
|
||||||
|
# peut être un nom, un email…).
|
||||||
|
request.state.error_code = "validation " + "; ".join(
|
||||||
|
f"{'.'.join(str(p) for p in err.get('loc', ()))}:{err.get('type')}"
|
||||||
|
for err in exc.errors()[:10]
|
||||||
|
)
|
||||||
|
return await request_validation_exception_handler(request, exc)
|
||||||
@@ -9,6 +9,7 @@ l'inscription au formulaire public n'échoue jamais pour autant.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@@ -24,9 +25,17 @@ class SumUpCheckout:
|
|||||||
id: str
|
id: str
|
||||||
status: str # PENDING / PAID / FAILED / EXPIRED (voir FeePaymentStatus)
|
status: str # PENDING / PAID / FAILED / EXPIRED (voir FeePaymentStatus)
|
||||||
hosted_checkout_url: str | None
|
hosted_checkout_url: str | None
|
||||||
|
# Transaction qui a réglé le checkout (renseignés par SumUp une fois
|
||||||
|
# payé, `None` avant) : `transaction_code` est le code court affiché dans
|
||||||
|
# le tableau de bord SumUp et sur ses reçus (ex. `TEENSK4W2K`) — c'est
|
||||||
|
# lui que la coordination recherche pour rapprocher un paiement d'un
|
||||||
|
# bénévole ; `transaction_id` est l'identifiant technique (API).
|
||||||
|
transaction_code: str | None = None
|
||||||
|
transaction_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
def _call(method: str, path: str, api_key: str, json: dict | None = None) -> dict | None:
|
def _call(method: str, path: str, api_key: str, json: dict | None = None) -> dict | None:
|
||||||
|
started = time.monotonic()
|
||||||
try:
|
try:
|
||||||
resp = httpx.request(
|
resp = httpx.request(
|
||||||
method,
|
method,
|
||||||
@@ -36,19 +45,71 @@ def _call(method: str, path: str, api_key: str, json: dict | None = None) -> dic
|
|||||||
timeout=_TIMEOUT_SECONDS,
|
timeout=_TIMEOUT_SECONDS,
|
||||||
)
|
)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
return resp.json()
|
data = resp.json()
|
||||||
except httpx.HTTPError as exc:
|
except httpx.HTTPStatusError as exc:
|
||||||
|
# Le corps d'erreur SumUp (`error_code`/`message`, voir leur doc) dit
|
||||||
|
# pourquoi l'appel est refusé (identifiants invalides, montant,
|
||||||
|
# référence déjà utilisée…) — il ne contient que des codes, pas de
|
||||||
|
# données du payeur.
|
||||||
|
logger.warning(
|
||||||
|
"Échec de l'appel API SumUp (%s %s) : HTTP %d %s",
|
||||||
|
method,
|
||||||
|
path,
|
||||||
|
exc.response.status_code,
|
||||||
|
_error_summary(exc.response),
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
except (httpx.HTTPError, ValueError) as exc:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Échec de l'appel API SumUp (%s %s) : %s", method, path, exc.__class__.__name__
|
"Échec de l'appel API SumUp (%s %s) : %s", method, path, exc.__class__.__name__
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
logger.debug(
|
||||||
|
"API SumUp %s %s : OK en %d ms",
|
||||||
|
method,
|
||||||
|
path,
|
||||||
|
(time.monotonic() - started) * 1000,
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _error_summary(resp: httpx.Response) -> str:
|
||||||
|
try:
|
||||||
|
body = resp.json()
|
||||||
|
except ValueError:
|
||||||
|
return ""
|
||||||
|
if isinstance(body, list): # SumUp renvoie parfois une liste d'erreurs
|
||||||
|
body = body[0] if body else {}
|
||||||
|
if not isinstance(body, dict):
|
||||||
|
return ""
|
||||||
|
return " ".join(
|
||||||
|
str(body[key])[:200] for key in ("error_code", "message", "param") if body.get(key)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _to_checkout(data: dict) -> SumUpCheckout:
|
def _to_checkout(data: dict) -> SumUpCheckout:
|
||||||
|
transaction_code = data.get("transaction_code")
|
||||||
|
transaction_id = data.get("transaction_id")
|
||||||
|
if not transaction_code:
|
||||||
|
# Selon la version de l'API, seuls les essais de paiement
|
||||||
|
# (`transactions`) portent le code : on retient celui qui a abouti.
|
||||||
|
successful = next(
|
||||||
|
(
|
||||||
|
tx
|
||||||
|
for tx in data.get("transactions") or []
|
||||||
|
if isinstance(tx, dict) and tx.get("status") == "SUCCESSFUL"
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if successful is not None:
|
||||||
|
transaction_code = successful.get("transaction_code")
|
||||||
|
transaction_id = transaction_id or successful.get("id")
|
||||||
return SumUpCheckout(
|
return SumUpCheckout(
|
||||||
id=data["id"],
|
id=data["id"],
|
||||||
status=data["status"],
|
status=data["status"],
|
||||||
hosted_checkout_url=data.get("hosted_checkout_url"),
|
hosted_checkout_url=data.get("hosted_checkout_url"),
|
||||||
|
transaction_code=transaction_code,
|
||||||
|
transaction_id=transaction_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -65,7 +126,13 @@ def create_checkout(
|
|||||||
) -> SumUpCheckout | None:
|
) -> SumUpCheckout | None:
|
||||||
"""Crée un checkout hébergé (`hosted_checkout.enabled=true`) : la page de
|
"""Crée un checkout hébergé (`hosted_checkout.enabled=true`) : la page de
|
||||||
paiement SumUp est à `hosted_checkout_url`. `None` en cas d'échec (réseau,
|
paiement SumUp est à `hosted_checkout_url`. `None` en cas d'échec (réseau,
|
||||||
identifiants invalides…)."""
|
identifiants invalides…).
|
||||||
|
|
||||||
|
L'API de création n'accepte pas de métadonnées libres : `description`
|
||||||
|
(« affichée dans les outils SumUp ») et `checkout_reference` sont les
|
||||||
|
seuls champs du marchand repris sur la transaction — c'est donc par eux
|
||||||
|
que passe le nom du bénévole (voir `app/api/forms.py:
|
||||||
|
_checkout_description`)."""
|
||||||
data = _call(
|
data = _call(
|
||||||
"POST",
|
"POST",
|
||||||
"/v0.1/checkouts",
|
"/v0.1/checkouts",
|
||||||
|
|||||||
@@ -588,3 +588,24 @@ def test_mecenat_receipts_is_coordinator_only(
|
|||||||
lead = make_account(Role.LEAD, email="lead@e.fr")
|
lead = make_account(Role.LEAD, email="lead@e.fr")
|
||||||
resp = as_account(lead).get(_url(edition, "/mecenat-receipts"))
|
resp = as_account(lead).get(_url(edition, "/mecenat-receipts"))
|
||||||
assert resp.status_code == 403
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_gives_the_sumup_transaction(client, edition, session):
|
||||||
|
volunteer = make_volunteer(session, edition, first_name="Sam", last_name="B")
|
||||||
|
_mark_mecenat_paid(session, volunteer)
|
||||||
|
volunteer.fee_transaction_code = "TEENSK4W2K"
|
||||||
|
volunteer.fee_transaction_id = "4ba8a7ba-0f61-4c8a-9b5c-2f3e0b4a8a11"
|
||||||
|
session.add(volunteer)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
resp = client.get(_url(edition, "/export"))
|
||||||
|
rows = list(load_workbook(io.BytesIO(resp.content)).active.iter_rows(values_only=True))
|
||||||
|
code = rows[0].index("Transaction SumUp (code)")
|
||||||
|
ident = rows[0].index("Transaction SumUp (identifiant)")
|
||||||
|
assert rows[1][code] == "TEENSK4W2K"
|
||||||
|
assert rows[1][ident] == "4ba8a7ba-0f61-4c8a-9b5c-2f3e0b4a8a11"
|
||||||
|
|
||||||
|
fiche = client.get(_url(edition, f"/{volunteer.id}")).json()
|
||||||
|
assert fiche["fee_transaction_code"] == "TEENSK4W2K"
|
||||||
|
# L'identifiant du checkout, lui, reste interne.
|
||||||
|
assert "fee_checkout_id" not in fiche
|
||||||
|
|||||||
@@ -716,6 +716,56 @@ def test_paying_promotes_the_pending_submission_to_a_volunteer(
|
|||||||
assert fiche["willing_to_pay_fee"] is True
|
assert fiche["willing_to_pay_fee"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_checkout_description_names_the_volunteer(raw_client, client, edition, monkeypatch):
|
||||||
|
# Pas de champ de métadonnées libre à la création d'un checkout SumUp :
|
||||||
|
# c'est la description (visible dans le tableau de bord SumUp) qui porte
|
||||||
|
# le nom, pour rapprocher la transaction du bénévole.
|
||||||
|
option_id = _configure_sumup(client, edition)
|
||||||
|
calls = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.api.forms.create_checkout",
|
||||||
|
lambda **kw: calls.append(kw)
|
||||||
|
or SumUpCheckout(id="co_1", status="PENDING", hosted_checkout_url="https://x"),
|
||||||
|
)
|
||||||
|
_submit(
|
||||||
|
raw_client, edition,
|
||||||
|
first_name="alex", last_name="martin", hcaptcha_token="x",
|
||||||
|
payment_option_id=option_id,
|
||||||
|
)
|
||||||
|
assert calls[0]["description"] == (
|
||||||
|
f"Alex Martin — Cotisation classique — édition {edition.year}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_promotion_records_the_sumup_transaction(raw_client, client, edition, monkeypatch):
|
||||||
|
option_id = _configure_sumup(client, edition)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.api.forms.create_checkout",
|
||||||
|
lambda **kw: SumUpCheckout(id="co_1", status="PENDING", hosted_checkout_url="https://x"),
|
||||||
|
)
|
||||||
|
token = _submit(
|
||||||
|
raw_client, edition,
|
||||||
|
first_name="Alex", last_name="Martin", hcaptcha_token="x",
|
||||||
|
payment_option_id=option_id,
|
||||||
|
).json()["pending_token"]
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.api.forms.get_checkout",
|
||||||
|
lambda **kw: SumUpCheckout(
|
||||||
|
id="co_1",
|
||||||
|
status="PAID",
|
||||||
|
hosted_checkout_url=None,
|
||||||
|
transaction_code="TEENSK4W2K",
|
||||||
|
transaction_id="tx-uuid-1",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
volunteer_id = _pending_status(raw_client, edition, token)["volunteer_id"]
|
||||||
|
|
||||||
|
fiche = client.get(f"/editions/{edition.id}/volunteers/{volunteer_id}").json()
|
||||||
|
assert fiche["fee_transaction_code"] == "TEENSK4W2K"
|
||||||
|
assert fiche["fee_transaction_id"] == "tx-uuid-1"
|
||||||
|
|
||||||
|
|
||||||
def test_payment_confirmation_sends_an_email_on_promotion(
|
def test_payment_confirmation_sends_an_email_on_promotion(
|
||||||
raw_client, client, edition, monkeypatch, outbox
|
raw_client, client, edition, monkeypatch, outbox
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"""Journal des requêtes (`app/request_logging.py`) : référence de requête,
|
||||||
|
route modèle sans jeton ni chaîne de requête, erreurs détaillées sans
|
||||||
|
valeur saisie."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.main import app
|
||||||
|
|
||||||
|
|
||||||
|
def test_each_response_carries_a_request_id(client):
|
||||||
|
resp = client.get("/health")
|
||||||
|
assert len(resp.headers["X-Request-ID"]) >= 8
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_well_formed_incoming_request_id_is_kept(client):
|
||||||
|
resp = client.get("/health", headers={"X-Request-ID": "caddy-1234abcd"})
|
||||||
|
assert resp.headers["X-Request-ID"] == "caddy-1234abcd"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_malformed_incoming_request_id_is_replaced(client):
|
||||||
|
resp = client.get("/health", headers={"X-Request-ID": "x\nforged log line"})
|
||||||
|
assert resp.headers["X-Request-ID"] != "x\nforged log line"
|
||||||
|
|
||||||
|
|
||||||
|
def test_access_line_uses_the_route_template_and_hides_tokens(raw_client, edition, caplog):
|
||||||
|
with caplog.at_level(logging.INFO, logger="app.request"):
|
||||||
|
raw_client.get(f"/editions/{edition.id}/form/public?token=secret-access-token")
|
||||||
|
|
||||||
|
line = next(r.getMessage() for r in caplog.records if r.name == "app.request")
|
||||||
|
assert "/editions/{edition_id}/form/public" in line
|
||||||
|
assert f"edition_id={edition.id}" in line
|
||||||
|
assert "secret-access-token" not in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_business_error_code_is_logged(client, edition, caplog):
|
||||||
|
with caplog.at_level(logging.INFO, logger="app.request"):
|
||||||
|
resp = client.get(f"/editions/{edition.id}/volunteers/00000000-0000-0000-0000-000000000000")
|
||||||
|
|
||||||
|
assert resp.status_code == 404
|
||||||
|
record = next(r for r in caplog.records if r.name == "app.request")
|
||||||
|
assert record.levelno == logging.WARNING
|
||||||
|
assert "code=error.not_found" in record.getMessage()
|
||||||
|
|
||||||
|
|
||||||
|
def test_validation_error_logs_the_field_but_not_the_value(client, edition, caplog):
|
||||||
|
with caplog.at_level(logging.INFO, logger="app.request"):
|
||||||
|
resp = client.post(
|
||||||
|
f"/editions/{edition.id}/volunteers",
|
||||||
|
json={"first_name": "Alexandra-Secret", "last_name": 12345678},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 422
|
||||||
|
assert "last_name" in caplog.text
|
||||||
|
assert "Alexandra-Secret" not in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def boom_route():
|
||||||
|
def boom():
|
||||||
|
raise RuntimeError("panne")
|
||||||
|
|
||||||
|
app.add_api_route("/__boom", boom)
|
||||||
|
yield
|
||||||
|
app.router.routes = [r for r in app.router.routes if getattr(r, "path", None) != "/__boom"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_unhandled_exception_returns_a_reference(boom_route, caplog):
|
||||||
|
with caplog.at_level(logging.ERROR, logger="app.request"):
|
||||||
|
resp = TestClient(app).get("/__boom")
|
||||||
|
|
||||||
|
assert resp.status_code == 500
|
||||||
|
body = resp.json()
|
||||||
|
assert body["detail"]["code"] == "error.internal"
|
||||||
|
assert body["detail"]["request_id"] == resp.headers["X-Request-ID"]
|
||||||
|
assert "RuntimeError" in caplog.text
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"""Client SumUp : lecture de la transaction d'un checkout payé, et
|
||||||
|
diagnostic d'un appel refusé (journalisé sans données du payeur)."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.sumup import client as sumup
|
||||||
|
|
||||||
|
|
||||||
|
def test_transaction_is_read_from_the_checkout():
|
||||||
|
checkout = sumup._to_checkout(
|
||||||
|
{
|
||||||
|
"id": "co_1",
|
||||||
|
"status": "PAID",
|
||||||
|
"transaction_code": "TEENSK4W2K",
|
||||||
|
"transaction_id": "tx-1",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert checkout.transaction_code == "TEENSK4W2K"
|
||||||
|
assert checkout.transaction_id == "tx-1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_transaction_falls_back_to_the_successful_attempt():
|
||||||
|
checkout = sumup._to_checkout(
|
||||||
|
{
|
||||||
|
"id": "co_1",
|
||||||
|
"status": "PAID",
|
||||||
|
"transactions": [
|
||||||
|
{"id": "tx-failed", "transaction_code": "TFAILED", "status": "FAILED"},
|
||||||
|
{"id": "tx-ok", "transaction_code": "TOK", "status": "SUCCESSFUL"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert checkout.transaction_code == "TOK"
|
||||||
|
assert checkout.transaction_id == "tx-ok"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unpaid_checkout_has_no_transaction():
|
||||||
|
checkout = sumup._to_checkout({"id": "co_1", "status": "PENDING"})
|
||||||
|
assert checkout.transaction_code is None
|
||||||
|
assert checkout.transaction_id is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_refused_call_logs_the_sumup_error_code(monkeypatch, caplog):
|
||||||
|
request = httpx.Request("POST", "https://api.sumup.com/v0.1/checkouts")
|
||||||
|
response = httpx.Response(
|
||||||
|
409,
|
||||||
|
json={"error_code": "DUPLICATED_CHECKOUT", "message": "Checkout already exists"},
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(httpx, "request", lambda *a, **kw: response)
|
||||||
|
|
||||||
|
with caplog.at_level(logging.WARNING, logger="app.sumup"):
|
||||||
|
result = sumup.get_checkout(api_key="k", checkout_id="co_1")
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
assert "HTTP 409" in caplog.text
|
||||||
|
assert "DUPLICATED_CHECKOUT" in caplog.text
|
||||||
@@ -70,7 +70,11 @@ export function createHttpApi(baseUrl: string): PlanningApi {
|
|||||||
? ((detail as { violations: [] }).violations ?? [])
|
? ((detail as { violations: [] }).violations ?? [])
|
||||||
: [];
|
: [];
|
||||||
if (res.status === 401 && !path.includes('/auth/')) notifyUnauthorized();
|
if (res.status === 401 && !path.includes('/auth/')) notifyUnauthorized();
|
||||||
throw new ApiError(res.status, code, violations);
|
const requestId = res.headers.get('X-Request-ID');
|
||||||
|
if (res.status >= 500) {
|
||||||
|
console.error(`API ${res.status} ${path} — référence ${requestId ?? '?'}`, detail);
|
||||||
|
}
|
||||||
|
throw new ApiError(res.status, code, violations, requestId);
|
||||||
}
|
}
|
||||||
return body as T;
|
return body as T;
|
||||||
}
|
}
|
||||||
@@ -99,7 +103,7 @@ export function createHttpApi(baseUrl: string): PlanningApi {
|
|||||||
// Corps non JSON (ex. erreur réseau) : garde le code générique.
|
// Corps non JSON (ex. erreur réseau) : garde le code générique.
|
||||||
}
|
}
|
||||||
if (res.status === 401) notifyUnauthorized();
|
if (res.status === 401) notifyUnauthorized();
|
||||||
throw new ApiError(res.status, code, []);
|
throw new ApiError(res.status, code, [], res.headers.get('X-Request-ID'));
|
||||||
}
|
}
|
||||||
return res.blob();
|
return res.blob();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -299,6 +299,8 @@ export function buildMockState(): MockState {
|
|||||||
mecenat_country: null,
|
mecenat_country: null,
|
||||||
fee_payment_status: null,
|
fee_payment_status: null,
|
||||||
fee_paid_at: null,
|
fee_paid_at: null,
|
||||||
|
fee_transaction_code: null,
|
||||||
|
fee_transaction_id: null,
|
||||||
created_at: NOW,
|
created_at: NOW,
|
||||||
updated_at: NOW,
|
updated_at: NOW,
|
||||||
});
|
});
|
||||||
@@ -316,6 +318,8 @@ export function buildMockState(): MockState {
|
|||||||
fee_amount: mecenat ? 40 : 15,
|
fee_amount: mecenat ? 40 : 15,
|
||||||
fee_payment_status: 'PAID',
|
fee_payment_status: 'PAID',
|
||||||
fee_paid_at: v.created_at,
|
fee_paid_at: v.created_at,
|
||||||
|
fee_transaction_code: `TDEMO${String(i).padStart(5, '0')}`,
|
||||||
|
fee_transaction_id: `00000000-0000-4000-8000-${String(i).padStart(12, '0')}`,
|
||||||
...(mecenat
|
...(mecenat
|
||||||
? { mecenat_address: '1 rue du Festival', mecenat_city: 'Rennes', mecenat_postal_code: '35000', mecenat_country: 'France' }
|
? { mecenat_address: '1 rue du Festival', mecenat_city: 'Rennes', mecenat_postal_code: '35000', mecenat_country: 'France' }
|
||||||
: {}),
|
: {}),
|
||||||
|
|||||||
@@ -244,6 +244,8 @@ function promotePending(pending: MockPendingSubmission): VolunteerRead {
|
|||||||
mecenat_country: pending.mecenatCountry,
|
mecenat_country: pending.mecenatCountry,
|
||||||
fee_payment_status: 'PAID',
|
fee_payment_status: 'PAID',
|
||||||
fee_paid_at: now,
|
fee_paid_at: now,
|
||||||
|
fee_transaction_code: `TMOCK${String(state.seq).padStart(5, '0')}`,
|
||||||
|
fee_transaction_id: `mock-transaction-${state.seq}`,
|
||||||
created_at: now,
|
created_at: now,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
};
|
};
|
||||||
@@ -1005,6 +1007,8 @@ export function createMockApi(): PlanningApi {
|
|||||||
mecenat_country: null,
|
mecenat_country: null,
|
||||||
fee_payment_status: null,
|
fee_payment_status: null,
|
||||||
fee_paid_at: null,
|
fee_paid_at: null,
|
||||||
|
fee_transaction_code: null,
|
||||||
|
fee_transaction_id: null,
|
||||||
created_at: now,
|
created_at: now,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
};
|
};
|
||||||
@@ -1310,6 +1314,8 @@ export function createMockApi(): PlanningApi {
|
|||||||
mecenat_country: null,
|
mecenat_country: null,
|
||||||
fee_payment_status: null,
|
fee_payment_status: null,
|
||||||
fee_paid_at: null,
|
fee_paid_at: null,
|
||||||
|
fee_transaction_code: null,
|
||||||
|
fee_transaction_id: null,
|
||||||
created_at: now,
|
created_at: now,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
};
|
};
|
||||||
@@ -1778,6 +1784,8 @@ export function createMockApi(): PlanningApi {
|
|||||||
mecenat_country: null,
|
mecenat_country: null,
|
||||||
fee_payment_status: null,
|
fee_payment_status: null,
|
||||||
fee_paid_at: null,
|
fee_paid_at: null,
|
||||||
|
fee_transaction_code: null,
|
||||||
|
fee_transaction_id: null,
|
||||||
created_at: now,
|
created_at: now,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -117,6 +117,12 @@ export interface VolunteerRead {
|
|||||||
fee_amount: number | null;
|
fee_amount: number | null;
|
||||||
fee_payment_status: FeePaymentStatus | null;
|
fee_payment_status: FeePaymentStatus | null;
|
||||||
fee_paid_at: string | null;
|
fee_paid_at: string | null;
|
||||||
|
/** Transaction SumUp qui a réglé la cotisation : `fee_transaction_code` est
|
||||||
|
* le code affiché dans le tableau de bord SumUp (ex. `TEENSK4W2K`),
|
||||||
|
* `fee_transaction_id` l'identifiant technique. `null` tant que non payée
|
||||||
|
* (ou payée avant que l'appli ne l'enregistre). */
|
||||||
|
fee_transaction_code: string | null;
|
||||||
|
fee_transaction_id: string | null;
|
||||||
/** Coordonnées pour un reçu fiscal (cerfa) — seulement si payé via un type
|
/** Coordonnées pour un reçu fiscal (cerfa) — seulement si payé via un type
|
||||||
* de paiement « mécénat ». */
|
* de paiement « mécénat ». */
|
||||||
mecenat_address: string | null;
|
mecenat_address: string | null;
|
||||||
|
|||||||
@@ -233,6 +233,9 @@ export class ApiError extends Error {
|
|||||||
public readonly status: number,
|
public readonly status: number,
|
||||||
public readonly code: string,
|
public readonly code: string,
|
||||||
public readonly violations: Violation[] = [],
|
public readonly violations: Violation[] = [],
|
||||||
|
/** En-tête `X-Request-ID` de la réponse : référence à communiquer pour
|
||||||
|
* retrouver l'erreur dans les logs du serveur (`app/request_logging.py`). */
|
||||||
|
public readonly requestId: string | null = null,
|
||||||
) {
|
) {
|
||||||
super(code);
|
super(code);
|
||||||
this.name = 'ApiError';
|
this.name = 'ApiError';
|
||||||
|
|||||||
@@ -35,6 +35,11 @@ export function ErrorNote({ error }: { error: unknown }) {
|
|||||||
<span className="mt-1 block font-mono text-[10px] font-normal opacity-80">
|
<span className="mt-1 block font-mono text-[10px] font-normal opacity-80">
|
||||||
{known ? t(code) : code}
|
{known ? t(code) : code}
|
||||||
</span>
|
</span>
|
||||||
|
{error instanceof ApiError && error.requestId ? (
|
||||||
|
<span className="mt-1 block select-all font-mono text-[10px] font-normal opacity-80">
|
||||||
|
{t('app.error.reference', { id: error.requestId })}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,9 @@
|
|||||||
"app.loading": "Chargement…",
|
"app.loading": "Chargement…",
|
||||||
"common.now": "MAINTENANT",
|
"common.now": "MAINTENANT",
|
||||||
"app.error.generic": "Une erreur est survenue",
|
"app.error.generic": "Une erreur est survenue",
|
||||||
|
"app.error.reference": "Référence à communiquer : {id}",
|
||||||
"error.generic": "Erreur inattendue",
|
"error.generic": "Erreur inattendue",
|
||||||
|
"error.internal": "Erreur interne du serveur",
|
||||||
"error.not_found": "Ressource introuvable",
|
"error.not_found": "Ressource introuvable",
|
||||||
"error.forbidden": "Accès refusé",
|
"error.forbidden": "Accès refusé",
|
||||||
"error.unauthorized": "Authentification requise",
|
"error.unauthorized": "Authentification requise",
|
||||||
@@ -307,6 +309,11 @@
|
|||||||
"config.vol.fee.mecenat": "Mécénat — reçu fiscal",
|
"config.vol.fee.mecenat": "Mécénat — reçu fiscal",
|
||||||
"config.vol.fee.onSite": "Paiera sur place",
|
"config.vol.fee.onSite": "Paiera sur place",
|
||||||
"config.vol.fee.none": "aucune cotisation déclarée",
|
"config.vol.fee.none": "aucune cotisation déclarée",
|
||||||
|
"config.vol.fee.paidAt": "Payée le",
|
||||||
|
"config.vol.fee.transaction": "Transaction SumUp",
|
||||||
|
"config.vol.fee.transactionHint": "Code de la transaction tel qu'affiché dans le tableau de bord SumUp : recherchez-le là-bas pour retrouver ce paiement. Côté SumUp, la description de la transaction porte aussi le nom du bénévole.",
|
||||||
|
"config.vol.fee.transactionUnknown": "non enregistrée (paiement antérieur à ce suivi)",
|
||||||
|
"config.vol.fee.transactionId": "Identifiant",
|
||||||
"config.vol.fee.onSiteShort": "Sur place",
|
"config.vol.fee.onSiteShort": "Sur place",
|
||||||
"config.vol.fee.onlineShort": "En ligne",
|
"config.vol.fee.onlineShort": "En ligne",
|
||||||
"config.vol.fee.mecenatShort": "Mécénat",
|
"config.vol.fee.mecenatShort": "Mécénat",
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ import { ErrorNote, Loading } from '@/components/state';
|
|||||||
import { feeMode } from '@/lib/fee';
|
import { feeMode } from '@/lib/fee';
|
||||||
import { foldAccents } from '@/lib/planning';
|
import { foldAccents } from '@/lib/planning';
|
||||||
import { t } from '@/i18n';
|
import { t } from '@/i18n';
|
||||||
import { shortDayLabel } from '@/lib/time';
|
import { datePart, shortDayLabel, timePart } from '@/lib/time';
|
||||||
import { cx } from '@/lib/cx';
|
import { cx } from '@/lib/cx';
|
||||||
|
|
||||||
const MAP_FIELDS: Array<{ key: ImportFieldKey; required: boolean }> = [
|
const MAP_FIELDS: Array<{ key: ImportFieldKey; required: boolean }> = [
|
||||||
@@ -273,6 +273,7 @@ function FeeSection({
|
|||||||
{volunteer.fee_payment_label} — {volunteer.fee_amount} €
|
{volunteer.fee_payment_label} — {volunteer.fee_amount} €
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
{volunteer.fee_payment_status === 'PAID' ? <FeeTransaction volunteer={volunteer} /> : null}
|
||||||
{volunteer.mecenat_address ? (
|
{volunteer.mecenat_address ? (
|
||||||
<p className="font-mono text-[10.5px] leading-relaxed opacity-70">
|
<p className="font-mono text-[10.5px] leading-relaxed opacity-70">
|
||||||
{t('config.vol.mecenatAddress')} : {volunteer.mecenat_address},{' '}
|
{t('config.vol.mecenatAddress')} : {volunteer.mecenat_address},{' '}
|
||||||
@@ -298,6 +299,45 @@ function FeeSection({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rapprochement avec SumUp : date du paiement et transaction qui l'a réglé.
|
||||||
|
* Le code (`TEENSK4W2K`) est celui que montre le tableau de bord SumUp —
|
||||||
|
* c'est lui qu'on y recherche pour retrouver le paiement de ce bénévole.
|
||||||
|
* Absent pour un paiement confirmé avant que l'appli ne l'enregistre.
|
||||||
|
*/
|
||||||
|
function FeeTransaction({ volunteer }: { volunteer: VolunteerRead }) {
|
||||||
|
const paidAt = volunteer.fee_paid_at;
|
||||||
|
return (
|
||||||
|
<dl className="grid grid-cols-[auto_1fr] gap-x-[10px] gap-y-[3px] font-mono text-[10.5px]">
|
||||||
|
{paidAt ? (
|
||||||
|
<>
|
||||||
|
<dt className="opacity-70">{t('config.vol.fee.paidAt')}</dt>
|
||||||
|
<dd>
|
||||||
|
{datePart(paidAt).split('-').reverse().join('/')} {timePart(paidAt)}
|
||||||
|
</dd>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
<dt className="flex items-center gap-[4px] opacity-70">
|
||||||
|
{t('config.vol.fee.transaction')}{' '}
|
||||||
|
<HelpTip text={t('config.vol.fee.transactionHint')} />
|
||||||
|
</dt>
|
||||||
|
<dd className="min-w-0">
|
||||||
|
{volunteer.fee_transaction_code ? (
|
||||||
|
<span className="select-all break-all font-bold">{volunteer.fee_transaction_code}</span>
|
||||||
|
) : (
|
||||||
|
<span className="opacity-60">{t('config.vol.fee.transactionUnknown')}</span>
|
||||||
|
)}
|
||||||
|
</dd>
|
||||||
|
{volunteer.fee_transaction_id ? (
|
||||||
|
<>
|
||||||
|
<dt className="opacity-70">{t('config.vol.fee.transactionId')}</dt>
|
||||||
|
<dd className="min-w-0 select-all break-all opacity-70">{volunteer.fee_transaction_id}</dd>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</dl>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Postes dont ce bénévole est référent (`Position.lead_account_id`). Cocher
|
* Postes dont ce bénévole est référent (`Position.lead_account_id`). Cocher
|
||||||
* un poste crée ou met à niveau son accès de connexion en RÉFÉRENT (LEAD) —
|
* un poste crée ou met à niveau son accès de connexion en RÉFÉRENT (LEAD) —
|
||||||
|
|||||||
Reference in New Issue
Block a user