Mode de paiement de la cotisation visible (en ligne, sur place)
ci-cd / build-backend (push) Successful in 31s
ci-cd / build-frontend (push) Successful in 18s
ci-cd / deploy (push) Successful in 12s

Jusqu'ici seul un paiement en ligne apparaissait ; un bénévole qui paiera
sur place ne se distinguait pas d'un bénévole sans cotisation. Mode déduit
des champs existants (lib/fee.ts:feeMode, exporting/xlsx.py:_payment_mode),
sans migration :
- fiche : section « Cotisation » toujours visible — paiement en ligne
  (type, montant, statut, mécénat et adresse du reçu) ou case « Paiera sur
  place » modifiable à la main
- liste : colonne « Cotisation » (Sur place / En ligne ✓ / Mécénat ✓ / —)
- export Excel : colonne « Mode de paiement »

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
AdamThomas
2026-10-03 18:47:06 +02:00
co-authored by Claude Opus 5.5
parent a6e7b6e7fb
commit ff57bbd863
6 changed files with 167 additions and 22 deletions
+1
View File
@@ -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 +
+13
View File
@@ -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, ""),
+18 -2
View File
@@ -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):
+13
View File
@@ -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",
+18
View File
@@ -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<VolunteerRead, 'fee_payment_status' | 'willing_to_pay_fee'>,
): FeeMode {
if (volunteer.fee_payment_status !== null) return 'ONLINE';
if (volunteer.willing_to_pay_fee) return 'ON_SITE';
return 'NONE';
}
+104 -20
View File
@@ -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 (
<div className="flex flex-col gap-2 border-2 border-ink bg-sand p-[9px]">
<FieldLabel>
@@ -232,20 +228,75 @@ function PublicFormAnswers({
/>
<span className="font-mono text-[11px]">{t('form.field.available')}</span>
</label>
{feeStatusKey ? (
<span className="border-2 border-ink bg-cream px-[8px] py-[3px] font-mono text-[10.5px] font-bold">
{volunteer.fee_payment_label ? `${volunteer.fee_payment_label} (${volunteer.fee_amount} €) — ` : ''}
{t(feeStatusKey)}
</span>
) : null}
</div>
</div>
);
}
{volunteer.mecenat_address ? (
<p className="font-mono text-[10.5px] leading-relaxed opacity-70">
{t('config.vol.mecenatAddress')} : {volunteer.mecenat_address}, {volunteer.mecenat_postal_code}{' '}
{volunteer.mecenat_city}, {volunteer.mecenat_country}
</p>
) : 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 (
<div className="flex flex-col gap-2 border-2 border-ink bg-sand p-[9px]">
<FieldLabel>{t('config.vol.fee.title')}</FieldLabel>
{mode === 'ONLINE' && volunteer.fee_payment_status ? (
<>
<div className="flex flex-wrap items-center gap-[6px]">
<span className="border-2 border-ink bg-blue px-[8px] py-[3px] font-mono text-[10.5px] font-bold">
{t('config.vol.fee.online')}
</span>
{volunteer.mecenat_address ? (
<span className="border-2 border-ink bg-mauve px-[8px] py-[3px] font-mono text-[10.5px] font-bold">
{t('config.vol.fee.mecenat')}
</span>
) : null}
<span
className={cx(
'border-2 border-ink px-[8px] py-[3px] font-mono text-[10.5px] font-bold',
volunteer.fee_payment_status === 'PAID' ? 'bg-green text-cream' : 'bg-yellow-soft',
)}
>
{t(FEE_STATUS_LABEL_KEY[volunteer.fee_payment_status])}
</span>
</div>
{volunteer.fee_payment_label ? (
<p className="font-mono text-[11px]">
{volunteer.fee_payment_label} — {volunteer.fee_amount} €
</p>
) : null}
{volunteer.mecenat_address ? (
<p className="font-mono text-[10.5px] leading-relaxed opacity-70">
{t('config.vol.mecenatAddress')} : {volunteer.mecenat_address},{' '}
{volunteer.mecenat_postal_code} {volunteer.mecenat_city}, {volunteer.mecenat_country}
</p>
) : null}
</>
) : (
<label className="flex cursor-pointer items-center gap-[7px]">
<input
type="checkbox"
checked={volunteer.willing_to_pay_fee}
onChange={(e) => patch({ willing_to_pay_fee: e.target.checked })}
className="h-[15px] w-[15px] accent-ink"
/>
<span className="font-mono text-[11px] font-bold">{t('config.vol.fee.onSite')}</span>
{mode === 'NONE' ? (
<span className="font-mono text-[10px] opacity-60">— {t('config.vol.fee.none')}</span>
) : null}
</label>
)}
</div>
);
}
@@ -483,6 +534,35 @@ function issueParams(issue: ImportIssue): Record<string, string | number> {
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 <span className="font-mono text-[10px] opacity-40">—</span>;
if (mode === 'ON_SITE') {
return (
<span className="truncate font-mono text-[10px] font-bold">{t('config.vol.fee.onSiteShort')}</span>
);
}
const paid = volunteer.fee_payment_status === 'PAID';
return (
<span className={cx('truncate font-mono text-[10px] font-bold', paid ? 'text-green' : 'text-orange-dark')}>
{t(volunteer.mecenat_address ? 'config.vol.fee.mecenatShort' : 'config.vol.fee.onlineShort')}
{paid ? ' ✓' : ` · ${t(FEE_STATUS_SHORT_KEY[volunteer.fee_payment_status ?? 'PENDING'])}`}
</span>
);
}
const FEE_STATUS_SHORT_KEY: Record<NonNullable<VolunteerRead['fee_payment_status']>, 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() {
<div className="border-[3px] border-ink bg-paper shadow-brutal-lg">
<div
className="grid gap-[10px] bg-ink px-[10px] py-[7px] font-display text-[9.5px] font-extrabold uppercase tracking-wide text-cream"
style={{ gridTemplateColumns: 'minmax(0,2fr) minmax(0,1.4fr) minmax(0,2fr) minmax(0,1.4fr)' }}
style={{ gridTemplateColumns: LIST_COLUMNS }}
>
<span>{t('config.vol.colName')}</span>
<span>{t('config.vol.colPhone')}</span>
<span>{t('config.vol.colEmail')}</span>
<span>{t('config.vol.colWishes')}</span>
<span>{t('config.vol.colFee')}</span>
</div>
{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 }}
>
<span className="flex min-w-0 items-center gap-[7px]">
<span className="flex h-6 w-6 flex-none items-center justify-center border-2 border-ink bg-paper font-display text-[9.5px] font-extrabold">
@@ -684,6 +765,7 @@ export function ConfigVolunteers() {
volunteer.wished_companion_ids.length,
})}
</span>
<FeeTag volunteer={volunteer} />
</button>
);
})}
@@ -784,6 +866,8 @@ export function ConfigVolunteers() {
searchPlaceholder={t('config.vol.searchCompanion')}
/>
<FeeSection volunteer={selected} patch={patch} />
<PublicFormAnswers volunteer={selected} patch={patch} />
<AvailabilityEditor volunteerId={selected.id} />