Mode de paiement de la cotisation visible (en ligne, sur place)
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:
co-authored by
Claude Opus 5.5
parent
a6e7b6e7fb
commit
ff57bbd863
@@ -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 +
|
||||
|
||||
@@ -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, ""),
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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} />
|
||||
|
||||
Reference in New Issue
Block a user