diff --git a/CLAUDE.md b/CLAUDE.md index 09a973d..1a17505 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -862,6 +862,7 @@ Aucune dépendance locale requise hors Docker. | 36 | Écran Inscriptions : état « Actif / Inactif » des types de paiement (`SumUpStatus`, `FormBuilderPage.tsx`) — sans identifiants SumUp ou sans type de paiement, le formulaire public n'affiche qu'une case « cotisation » (ni choix du type, ni étape mécénat, ni paiement en ligne), ce que rien ne signalait | à valider | | 37 | Cerfa mécénat : nouveau modèle PDF, date du versement repositionnée, date de signature remplie (date du paiement), case « Virement, prélèvement, carte bancaire » cochée (paiement SumUp) ; message de confirmation du paiement annonçant l'email et le reçu fiscal | à valider | | 38 | Créneaux de nuit : une heure de début avant le début de journée (premier créneau type) saisie sur la carte d'un jour désigne la nuit qui suit (00:00 le samedi ⇒ dimanche 00:00) ; écran « Postes & créneaux », feuilles de poste et chaînage du solveur regroupent par journée de festival, comme la grille et la validation | à valider | +| 39 | Mode de paiement de la cotisation visible partout (`lib/fee.ts:feeMode`, `app/exporting/xlsx.py:_payment_mode`) : en ligne (type, montant, statut, mécénat), sur place, ou rien — section « Cotisation » sur la fiche (case « Paiera sur place » modifiable à la main), colonne « Cotisation » dans la liste, colonne « Mode de paiement » dans l'export Excel | à valider | **Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans + diff --git a/backend/app/exporting/xlsx.py b/backend/app/exporting/xlsx.py index 697e0ed..eda91ef 100644 --- a/backend/app/exporting/xlsx.py +++ b/backend/app/exporting/xlsx.py @@ -24,6 +24,7 @@ _HEADER = [ "Artistes souhaités", "Compagnons souhaités", "Note de compagnon", + "Mode de paiement", "Cotisation choisie", "Montant (€)", "Statut du paiement", @@ -56,6 +57,17 @@ def _safe_text(value: str) -> str: return f"'{value}" if value.startswith(_FORMULA_PREFIXES) else value +def _payment_mode(v: VolunteerRead) -> str: + """En ligne (un checkout SumUp a été créé), sur place (« je participe à + la cotisation » sans paiement en ligne), ou rien — même règle que + `frontend/src/lib/fee.ts:feeMode`.""" + if v.fee_payment_status is not None: + return "En ligne" + if v.willing_to_pay_fee: + return "Sur place" + return "" + + def _yes_no(value: bool) -> str: return "Oui" if value else "Non" @@ -97,6 +109,7 @@ def generate_volunteers_xlsx( _join_names(v.artist_ids, artist_names), _join_names(v.wished_companion_ids, volunteer_names), _safe_text(v.companion_wish_note or ""), + _payment_mode(v), _safe_text(v.fee_payment_label or ""), v.fee_amount if v.fee_amount is not None else "", _FEE_STATUS_LABELS.get(v.fee_payment_status, ""), diff --git a/backend/tests/test_api_volunteers.py b/backend/tests/test_api_volunteers.py index 6546275..ee57abb 100644 --- a/backend/tests/test_api_volunteers.py +++ b/backend/tests/test_api_volunteers.py @@ -427,6 +427,22 @@ def test_export_volunteers_as_excel(client, edition, session): assert row[6] == "Chilla" +def test_export_says_how_each_volunteer_pays(client, edition, session): + on_site = make_volunteer(session, edition, first_name="Alex", last_name="A") + on_site.willing_to_pay_fee = True + online = make_volunteer(session, edition, first_name="Sam", last_name="B") + _mark_mecenat_paid(session, online) + make_volunteer(session, edition, first_name="Jo", last_name="C") + session.add(on_site) + session.commit() + + resp = client.get(_url(edition, "/export")) + rows = list(load_workbook(io.BytesIO(resp.content)).active.iter_rows(values_only=True)) + column = rows[0].index("Mode de paiement") + by_first_name = {row[0]: row[column] for row in rows[1:]} + assert by_first_name == {"Alex": "Sur place", "Sam": "En ligne", "Jo": None} + + def test_export_neutralizes_formula_injection(client, edition, session): # CWE-1236 : un bénévole inscrit via le formulaire public (sans # authentification) contrôle son nom, son commentaire, etc. — ouverts @@ -443,9 +459,9 @@ def test_export_neutralizes_formula_injection(client, edition, session): resp = client.get(_url(edition, "/export")) workbook = load_workbook(io.BytesIO(resp.content)) - row = list(workbook.active.iter_rows(values_only=True))[1] + header, row = list(workbook.active.iter_rows(values_only=True))[:2] assert row[0] == "'=cmd|'/c calc'!A0" - assert row[17] == "'@SUM(1+1)*cmd|' /C calc'!A0" + assert row[header.index("Commentaire")] == "'@SUM(1+1)*cmd|' /C calc'!A0" def test_export_volunteers_is_coordinator_only(edition, make_account, as_account): diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json index b69dc3c..9ec4fb9 100644 --- a/frontend/src/i18n/locales/fr.json +++ b/frontend/src/i18n/locales/fr.json @@ -311,6 +311,19 @@ "config.vol.publicForm.title": "Formulaire public", "config.vol.publicForm.hint": "Réponses saisies par le bénévole lui-même via le formulaire public d'inscription.", "config.vol.colWishes": "VŒUX", + "config.vol.colFee": "COTISATION", + "config.vol.fee.title": "Cotisation", + "config.vol.fee.online": "Paiement en ligne (SumUp)", + "config.vol.fee.mecenat": "Mécénat — reçu fiscal", + "config.vol.fee.onSite": "Paiera sur place", + "config.vol.fee.none": "aucune cotisation déclarée", + "config.vol.fee.onSiteShort": "Sur place", + "config.vol.fee.onlineShort": "En ligne", + "config.vol.fee.mecenatShort": "Mécénat", + "config.vol.fee.short.pending": "en attente", + "config.vol.fee.short.paid": "payée", + "config.vol.fee.short.failed": "échouée", + "config.vol.fee.short.expired": "expirée", "config.vol.wishesCount": "{count} vœu(x)", "config.vol.addAvailabilityHint": "Les disponibilités se renseignent sur la fiche du bénévole, une fois celui-ci créé.", "config.vol.avail.title": "Disponibilités", diff --git a/frontend/src/lib/fee.ts b/frontend/src/lib/fee.ts new file mode 100644 index 0000000..e53901e --- /dev/null +++ b/frontend/src/lib/fee.ts @@ -0,0 +1,18 @@ +import type { VolunteerRead } from '@/api/schema'; + +/** Comment un bénévole règle sa cotisation — déduit des champs existants, + * même règle que `app/exporting/xlsx.py:_payment_mode` : + * - `ONLINE` : un paiement SumUp a été lancé (type, montant et statut sur + * `fee_payment_*`) ; + * - `ON_SITE` : « je participe à la cotisation » sans paiement en ligne + * (« Je paierai sur place » au formulaire, ou case cochée à la main) ; + * - `NONE` : rien de déclaré. */ +export type FeeMode = 'ONLINE' | 'ON_SITE' | 'NONE'; + +export function feeMode( + volunteer: Pick, +): FeeMode { + if (volunteer.fee_payment_status !== null) return 'ONLINE'; + if (volunteer.willing_to_pay_fee) return 'ON_SITE'; + return 'NONE'; +} diff --git a/frontend/src/pages/config/ConfigVolunteers.tsx b/frontend/src/pages/config/ConfigVolunteers.tsx index 5f38dec..fcaf5f4 100644 --- a/frontend/src/pages/config/ConfigVolunteers.tsx +++ b/frontend/src/pages/config/ConfigVolunteers.tsx @@ -37,6 +37,7 @@ import type { } from '@/api/schema'; import { Chip, FieldLabel, HelpTip, TextInput } from '@/components/ui'; import { ErrorNote, Loading } from '@/components/state'; +import { feeMode } from '@/lib/fee'; import { foldAccents } from '@/lib/planning'; import { t } from '@/i18n'; import { shortDayLabel } from '@/lib/time'; @@ -188,14 +189,9 @@ function PublicFormAnswers({ const hasAnything = volunteer.has_photo || Boolean(volunteer.companion_wish_note) || - volunteer.self_declared_available || - volunteer.fee_payment_status !== null; + volunteer.self_declared_available; if (!hasAnything) return null; - const feeStatusKey = volunteer.fee_payment_status - ? FEE_STATUS_LABEL_KEY[volunteer.fee_payment_status] - : null; - return (
@@ -232,20 +228,75 @@ function PublicFormAnswers({ /> {t('form.field.available')} - {feeStatusKey ? ( - - {volunteer.fee_payment_label ? `${volunteer.fee_payment_label} (${volunteer.fee_amount} €) — ` : ''} - {t(feeStatusKey)} - - ) : null}
+ + ); +} - {volunteer.mecenat_address ? ( -

- {t('config.vol.mecenatAddress')} : {volunteer.mecenat_address}, {volunteer.mecenat_postal_code}{' '} - {volunteer.mecenat_city}, {volunteer.mecenat_country} -

- ) : null} +/** + * Cotisation du bénévole (voir `lib/fee.ts:feeMode`) : paiement en ligne + * (type, montant, statut, mécénat), paiement sur place, ou rien. Toujours + * affichée, pour qu'un « rien » se voie aussi ; le paiement sur place reste + * modifiable à la main (bénévole ajouté sans le formulaire, changement + * d'avis…), un paiement en ligne non — son statut vient de SumUp. + */ +function FeeSection({ + volunteer, + patch, +}: { + volunteer: VolunteerRead; + patch: (body: VolunteerUpdate) => void; +}) { + const mode = feeMode(volunteer); + return ( +
+ {t('config.vol.fee.title')} + {mode === 'ONLINE' && volunteer.fee_payment_status ? ( + <> +
+ + {t('config.vol.fee.online')} + + {volunteer.mecenat_address ? ( + + {t('config.vol.fee.mecenat')} + + ) : null} + + {t(FEE_STATUS_LABEL_KEY[volunteer.fee_payment_status])} + +
+ {volunteer.fee_payment_label ? ( +

+ {volunteer.fee_payment_label} — {volunteer.fee_amount} € +

+ ) : null} + {volunteer.mecenat_address ? ( +

+ {t('config.vol.mecenatAddress')} : {volunteer.mecenat_address},{' '} + {volunteer.mecenat_postal_code} {volunteer.mecenat_city}, {volunteer.mecenat_country} +

+ ) : null} + + ) : ( + + )}
); } @@ -483,6 +534,35 @@ function issueParams(issue: ImportIssue): Record { return out; } +const LIST_COLUMNS = + 'minmax(0,2fr) minmax(0,1.4fr) minmax(0,2fr) minmax(0,1.2fr) minmax(0,1.2fr)'; + +/** Résumé d'une ligne de la liste : mode de paiement (et mécénat), statut + * d'un paiement en ligne non abouti. */ +function FeeTag({ volunteer }: { volunteer: VolunteerRead }) { + const mode = feeMode(volunteer); + if (mode === 'NONE') return —; + if (mode === 'ON_SITE') { + return ( + {t('config.vol.fee.onSiteShort')} + ); + } + const paid = volunteer.fee_payment_status === 'PAID'; + return ( + + {t(volunteer.mecenat_address ? 'config.vol.fee.mecenatShort' : 'config.vol.fee.onlineShort')} + {paid ? ' ✓' : ` · ${t(FEE_STATUS_SHORT_KEY[volunteer.fee_payment_status ?? 'PENDING'])}`} + + ); +} + +const FEE_STATUS_SHORT_KEY: Record, string> = { + PENDING: 'config.vol.fee.short.pending', + PAID: 'config.vol.fee.short.paid', + FAILED: 'config.vol.fee.short.failed', + EXPIRED: 'config.vol.fee.short.expired', +}; + export function ConfigVolunteers() { const volunteers = useVolunteers(); const artists = useArtists(); @@ -641,12 +721,13 @@ export function ConfigVolunteers() {
{t('config.vol.colName')} {t('config.vol.colPhone')} {t('config.vol.colEmail')} {t('config.vol.colWishes')} + {t('config.vol.colFee')}
{rows.map((volunteer) => { const active = volunteer.id === selected?.id; @@ -659,7 +740,7 @@ export function ConfigVolunteers() { 'grid w-full items-center gap-[10px] border-b border-sand-line px-[10px] py-[7px] text-left', active ? 'bg-yellow' : 'hover:bg-cream', )} - style={{ gridTemplateColumns: 'minmax(0,2fr) minmax(0,1.4fr) minmax(0,2fr) minmax(0,1.4fr)' }} + style={{ gridTemplateColumns: LIST_COLUMNS }} > @@ -684,6 +765,7 @@ export function ConfigVolunteers() { volunteer.wished_companion_ids.length, })} + ); })} @@ -784,6 +866,8 @@ export function ConfigVolunteers() { searchPlaceholder={t('config.vol.searchCompanion')} /> + +