Inscription en deux temps, emails de confirmation et export Excel des bénévoles
ci-cd / build-backend (push) Successful in 30s
ci-cd / build-frontend (push) Successful in 18s
ci-cd / deploy (push) Successful in 12s

Formulaire public — paiement en ligne :
- Le bénévole n'est plus compté en base tant qu'un paiement en ligne choisi
  n'est pas confirmé (PendingFormSubmission, promu en Volunteer à la
  confirmation, webhook ou vérification en direct).
- Type de paiement « mécénat » : étape d'adresse postale avant le paiement,
  conservée sur le bénévole pour un reçu fiscal (cerfa) ultérieur.
- Page de confirmation au retour de SumUp, avec relance possible après
  échec/expiration sans ressaisir le formulaire.
- « Je paierai sur place » remplace « je ne participe pas » ; tous les
  champs activés par la coordination sont désormais obligatoires.
- La fiche bénévole n'affiche plus l'état des cases à cocher, seulement le
  résultat réel du paiement (libellé, montant, statut).

Emails :
- Email de confirmation d'inscription à la création du bénévole (tient lieu
  de reçu de paiement quand une cotisation est payée en ligne).
- Mise en forme HTML au thème de l'application (logo de l'édition en
  en-tête) pour tous les emails envoyés, texte brut conservé en repli.

Export Excel des bénévoles (COORDINATION) : coordonnées, vœux résolus en
noms lisibles, cotisation et mécénat.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
AdamThomas
2026-09-30 07:41:58 +02:00
co-authored by Claude Sonnet 5
parent 68d42ee303
commit a2b748566b
34 changed files with 2318 additions and 291 deletions
+38
View File
@@ -28,6 +28,7 @@ import type {
FormPaymentOptionUpdate,
FormTemplateUpdate,
ImportMapping,
MecenatInfoSubmit,
PlanningNotificationRequest,
PositionCreate,
PositionSheetsRequest,
@@ -384,6 +385,15 @@ export function useDeleteVolunteer() {
});
}
/** Export Excel de la liste des bénévoles ; ne fait que retourner le
* fichier (le composant appelant déclenche le téléchargement, voir
* `useGenerateBadges`). */
export function useExportVolunteers() {
return useMutation({
mutationFn: () => api.volunteers.exportXlsx(edition),
});
}
/** Remplace toutes les fenêtres de disponibilité d'un bénévole. */
export function useReplaceAvailability() {
const invalidate = useInvalidate();
@@ -713,3 +723,31 @@ export function useSubmitPublicForm() {
mutationFn: (body: PublicFormSubmission) => api.forms.submitPublic(edition, body),
});
}
export function useSubmitMecenatInfo() {
return useMutation({
mutationFn: ({ token, body }: { token: string; body: MecenatInfoSubmit }) =>
api.forms.submitMecenatInfo(edition, token, body),
});
}
/** Statut d'une inscription en attente de paiement en ligne — interrogé en
* direct (voir CLAUDE.md § 2, Paiement de la cotisation) : la page de
* confirmation reste en attente tant que `status` vaut `PENDING`. */
export function usePendingStatus(token: string | null) {
return useQuery({
queryKey: ['pendingStatus', edition, token],
queryFn: () => api.forms.getPendingStatus(edition, token as string),
enabled: token !== null,
// Arrête la vérification automatique après ~2 min (40 x 3 s) : au-delà,
// un bouton manuel dans PaymentConfirmationView permet de réessayer.
refetchInterval: (query) =>
query.state.data?.status === 'PENDING' && query.state.dataUpdateCount < 40 ? 3000 : false,
});
}
export function useRetryPendingPayment() {
return useMutation({
mutationFn: (token: string) => api.forms.retryPendingPayment(edition, token),
});
}
+17 -3
View File
@@ -20,6 +20,7 @@ import type {
ImportMapping,
LoginRequest,
MagicLinkRequest,
MecenatInfoSubmit,
PlanningNotificationRequest,
PositionCreate,
PositionSheetsRequest,
@@ -151,6 +152,11 @@ export function createHttpApi(baseUrl: string): PlanningApi {
planning: (editionId, id) =>
request(`${base(editionId)}/volunteers/${id}/planning`),
photoBlob: (editionId, id) => requestBlob(`${base(editionId)}/volunteers/${id}/photo`),
exportXlsx: async (editionId) => {
const blob = await requestBlob(`${base(editionId)}/volunteers/export`);
if (!blob) throw new ApiError(500, 'error.generic', []);
return blob;
},
},
accounts: {
list: (editionId) => request(`${base(editionId)}/accounts`),
@@ -295,9 +301,6 @@ export function createHttpApi(baseUrl: string): PlanningApi {
if (body.comment) form.append('comment', body.comment);
if (body.companion_wish_note) form.append('companion_wish_note', body.companion_wish_note);
if (body.available !== undefined) form.append('available', String(body.available));
if (body.willing_to_pay_fee !== undefined) {
form.append('willing_to_pay_fee', String(body.willing_to_pay_fee));
}
if (body.payment_option_id) form.append('payment_option_id', body.payment_option_id);
if (body.position_wish_ids) {
form.append('position_wish_ids', JSON.stringify(body.position_wish_ids));
@@ -323,6 +326,17 @@ export function createHttpApi(baseUrl: string): PlanningApi {
remove: (editionId, id) =>
request(`${base(editionId)}/form/payment-options/${id}`, { method: 'DELETE' }),
},
submitMecenatInfo: (editionId, token, body: MecenatInfoSubmit) =>
request(`${base(editionId)}/form/public/pending/${token}/mecenat-info`, {
method: 'POST',
body: JSON.stringify(body),
}),
getPendingStatus: (editionId, token) =>
request(`${base(editionId)}/form/public/pending/${token}`),
retryPendingPayment: (editionId, token) =>
request(`${base(editionId)}/form/public/pending/${token}/retry-payment`, {
method: 'POST',
}),
},
positionSheets: {
generate: async (editionId, body: PositionSheetsRequest) => {
+40
View File
@@ -8,6 +8,7 @@ import type {
BadgeFieldRead,
DayPartRead,
EditionRead,
FeePaymentStatus,
FormFieldType,
FormPaymentOptionRead,
PositionCategory,
@@ -16,6 +17,37 @@ import type {
VolunteerRead,
} from '../schema';
/** Inscription en attente de paiement en ligne (mode démonstration) — voir
* `PendingFormSubmission` côté back-end (CLAUDE.md § 2, Paiement de la
* cotisation). `pollCount` simule le délai de confirmation réel (webhook
* asynchrone) : la première vérification reste `PENDING`, la suivante
* confirme — sans vrai réseau ni vrai SumUp en mode mock. */
export interface MockPendingSubmission {
id: string;
editionId: string;
firstName: string | null;
lastName: string | null;
email: string | null;
phone: string | null;
comment: string | null;
companionWishNote: string | null;
available: boolean;
positionIds: string[];
artistIds: string[];
photo: string | null; // data URL
paymentLabel: string;
paymentAmount: number;
requiresMecenatInfo: boolean;
mecenatAddress: string | null;
mecenatCity: string | null;
mecenatPostalCode: string | null;
mecenatCountry: string | null;
checkoutId: string | null;
status: FeePaymentStatus | null;
promotedVolunteerId: string | null;
pollCount: number;
}
const PART_LABELS: Record<string, string> = { M: 'Matin', A: 'Après-midi', S: 'Soirée', N: 'Nuit' };
const PART_CODES: Record<string, string> = { M: 'MATIN', A: 'APRES_MIDI', S: 'SOIREE', N: 'NUIT' };
const hhmm = (h: number) => `${String(h % 24).padStart(2, '0')}:00:00`;
@@ -61,6 +93,9 @@ export interface MockState {
sumupApiKey: string | null;
};
formPaymentOptions: FormPaymentOptionRead[];
/** Inscriptions en attente de paiement en ligne (voir CLAUDE.md § 2,
* Paiement de la cotisation) — clé : jeton public (id). */
pendingSubmissions: Map<string, MockPendingSubmission>;
/** volunteerId -> photo (formulaire public), data URL. */
photos: Map<string, string>;
seq: number;
@@ -278,6 +313,10 @@ export function buildMockState(): MockState {
willing_to_pay_fee: false,
fee_payment_label: null,
fee_amount: null,
mecenat_address: null,
mecenat_city: null,
mecenat_postal_code: null,
mecenat_country: null,
fee_payment_status: null,
fee_paid_at: null,
created_at: NOW,
@@ -458,6 +497,7 @@ export function buildMockState(): MockState {
sumupMerchantCode: null, sumupApiKey: null,
},
formPaymentOptions: [],
pendingSubmissions: new Map(),
photos: new Map(),
seq,
};
+251 -44
View File
@@ -32,6 +32,8 @@ import type {
ImportReport,
ImportRowReport,
MatrixResponse,
MecenatInfoSubmit,
PendingSubmissionStatus,
PlanningNotificationRequest,
PositionSheetsRequest,
PublicFormConfig,
@@ -45,7 +47,7 @@ import type {
} from '../schema';
import { getAuthToken } from '../session';
import { ApiError, type PlanningApi } from '../types';
import { buildMockState, type MockState } from './dataset';
import { buildMockState, type MockPendingSubmission, type MockState } from './dataset';
// Implémentation en mémoire de `PlanningApi`. Un délai réseau simulé rend le
// comportement (chargements, invalidations) proche du back-end réel.
@@ -159,6 +161,14 @@ const MOCK_PDF_BLOB = () =>
{ type: 'application/pdf' },
);
// Contenu factice (pas un vrai classeur) — la génération réelle (openpyxl)
// est réservée au back-end ; en mode démonstration, seul le déclenchement
// du téléchargement est simulé.
const MOCK_XLSX_BLOB = () =>
new Blob(['Export bénévoles (démonstration)'], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
});
const touch = () => new Date().toISOString().replace(/\.\d+Z$/, '');
const addDays = (day: string, n: number): string => {
@@ -197,6 +207,74 @@ function resolveWishes<T extends { id: string }>(
return wanted;
}
/** Comme `resolveWishes` mais silencieux : filtre les ids toujours valides
* sans jamais lever — utilisé à la promotion d'une inscription en attente
* (le paiement a déjà eu lieu, voir `promotePending`). */
function filterKnownIds<T extends { id: string }>(pool: T[], ids: string[]): string[] {
const known = new Set(pool.map((x) => x.id));
return [...new Set(ids)].filter((id) => known.has(id));
}
/** Paiement confirmé : crée enfin le `Volunteer` (voir le docstring de
* `PendingFormSubmission` côté back-end) puis vide les champs nominatifs de
* l'inscription en attente. */
function promotePending(pending: MockPendingSubmission): VolunteerRead {
const now = touch();
const volunteer: VolunteerRead = {
id: `v${state.seq++}`,
edition_id: state.edition.id,
first_name: pending.firstName ?? '',
last_name: pending.lastName ?? '',
email: pending.email,
phone: pending.phone,
comment: pending.comment,
artist_ids: filterKnownIds(state.artists, pending.artistIds),
wished_position_ids: filterKnownIds(state.positions, pending.positionIds),
wished_companion_ids: [],
has_photo: pending.photo !== null,
companion_wish_note: pending.companionWishNote,
self_declared_available: pending.available,
willing_to_pay_fee: true,
fee_payment_label: pending.paymentLabel,
fee_amount: pending.paymentAmount,
mecenat_address: pending.mecenatAddress,
mecenat_city: pending.mecenatCity,
mecenat_postal_code: pending.mecenatPostalCode,
mecenat_country: pending.mecenatCountry,
fee_payment_status: 'PAID',
fee_paid_at: now,
created_at: now,
updated_at: now,
};
if (pending.photo) state.photos.set(volunteer.id, pending.photo);
state.volunteers.push(volunteer);
state.availability.set(volunteer.id, []);
pending.promotedVolunteerId = volunteer.id;
pending.status = 'PAID';
pending.firstName = null;
pending.lastName = null;
pending.email = null;
pending.phone = null;
pending.comment = null;
pending.companionWishNote = null;
pending.photo = null;
pending.mecenatAddress = null;
pending.mecenatCity = null;
pending.mecenatPostalCode = null;
pending.mecenatCountry = null;
return volunteer;
}
/** Simule la création (ou relance) d'un checkout SumUp — pas de vrai appel
* réseau en mode démonstration, voir `app/api/forms.py:_create_pending_checkout`. */
function createPendingCheckout(pending: MockPendingSubmission): string {
pending.checkoutId = `co-mock-${state.seq++}`;
pending.status = 'PENDING';
pending.pollCount = 0;
return `https://checkout.sumup.com/pay/mock-${pending.checkoutId}`;
}
/** Vœux de compagnon : comme `resolveWishes`, plus le rejet de soi-même. */
function resolveCompanionWishes(ids: string[], selfId?: string): string[] {
const resolved = resolveWishes(state.volunteers, ids, 'volunteer.unknown');
@@ -872,6 +950,10 @@ export function createMockApi(): PlanningApi {
willing_to_pay_fee: false,
fee_payment_label: null,
fee_amount: null,
mecenat_address: null,
mecenat_city: null,
mecenat_postal_code: null,
mecenat_country: null,
fee_payment_status: null,
fee_paid_at: null,
created_at: now,
@@ -935,6 +1017,7 @@ export function createMockApi(): PlanningApi {
if (!dataUrl) return null;
return (await fetch(dataUrl)).blob();
},
exportXlsx: () => delay(MOCK_XLSX_BLOB()),
planning: (_edition, id) => {
if (!state.volunteers.some((v) => v.id === id)) return notFound();
const entries = state.assignments
@@ -1211,6 +1294,10 @@ export function createMockApi(): PlanningApi {
willing_to_pay_fee: false,
fee_payment_label: null,
fee_amount: null,
mecenat_address: null,
mecenat_city: null,
mecenat_postal_code: null,
mecenat_country: null,
fee_payment_status: null,
fee_paid_at: null,
created_at: now,
@@ -1545,6 +1632,7 @@ export function createMockApi(): PlanningApi {
id: o.id,
label: o.label,
amount: o.amount,
requires_mecenat_info: o.requires_mecenat_info,
})),
});
},
@@ -1563,6 +1651,7 @@ export function createMockApi(): PlanningApi {
label,
amount: body.amount,
order: body.order ?? 0,
requires_mecenat_info: body.requires_mecenat_info ?? false,
created_at: now,
updated_at: now,
};
@@ -1585,6 +1674,9 @@ export function createMockApi(): PlanningApi {
label,
...(body.amount !== undefined ? { amount: body.amount } : {}),
...(body.order !== undefined ? { order: body.order } : {}),
...(body.requires_mecenat_info !== undefined
? { requires_mecenat_info: body.requires_mecenat_info }
: {}),
updated_at: touch(),
});
return delay<FormPaymentOptionRead>(option);
@@ -1604,8 +1696,18 @@ export function createMockApi(): PlanningApi {
}
const fields = new Set(state.formTemplate.fields);
const email = body.email?.trim() || null;
const phone = body.phone?.trim() || null;
const available = Boolean(body.available);
if (fields.has('EMAIL') && !email) throw new ApiError(422, 'import.row.missing_required_field');
if (fields.has('PHONE') && !phone) throw new ApiError(422, 'import.row.missing_required_field');
if (fields.has('AVAILABLE') && !available) {
throw new ApiError(422, 'import.row.missing_required_field');
}
const positionIds = fields.has('POSITION_WISHES') ? (body.position_wish_ids ?? []) : [];
if (positionIds.length > 2) throw new ApiError(422, 'form.too_many_position_wishes');
if (positionIds.length) resolveWishes(state.positions, positionIds, 'position.unknown');
const artistIds = fields.has('ARTIST_WISHES') ? (body.artist_wish_ids ?? []) : [];
const artistDates = artistIds.map(
(id) => state.artists.find((a) => a.id === id)?.performance_date,
@@ -1613,6 +1715,7 @@ export function createMockApi(): PlanningApi {
if (new Set(artistDates).size !== artistDates.length) {
throw new ApiError(422, 'form.too_many_artists_same_day');
}
if (artistIds.length) resolveWishes(state.artists, artistIds, 'artist.unknown');
let paymentOption: FormPaymentOptionRead | null = null;
if (fields.has('WILLING_TO_PAY_FEE') && body.payment_option_id) {
@@ -1620,53 +1723,157 @@ export function createMockApi(): PlanningApi {
if (!paymentOption) throw new ApiError(422, 'form.unknown_payment_option');
}
const now = touch();
const volunteer: VolunteerRead = {
id: `v${state.seq++}`,
edition_id: state.edition.id,
first_name: titleCase(body.first_name.trim()),
last_name: titleCase(body.last_name.trim()),
email: fields.has('EMAIL') ? body.email?.trim() || null : null,
phone: fields.has('PHONE') ? body.phone?.trim() || null : null,
comment: fields.has('COMMENT') ? body.comment?.trim() || null : null,
artist_ids: resolveWishes(state.artists, artistIds, 'artist.unknown'),
wished_position_ids: resolveWishes(state.positions, positionIds, 'position.unknown'),
wished_companion_ids: [],
has_photo: false,
companion_wish_note: fields.has('COMPANION_WISH')
? body.companion_wish_note?.trim() || null
: null,
self_declared_available: fields.has('AVAILABLE') ? Boolean(body.available) : false,
willing_to_pay_fee: fields.has('WILLING_TO_PAY_FEE')
? Boolean(body.willing_to_pay_fee) || paymentOption !== null
: false,
fee_payment_label: null,
fee_amount: null,
fee_payment_status: null,
fee_paid_at: null,
created_at: now,
updated_at: now,
const comment = fields.has('COMMENT') ? body.comment?.trim() || null : null;
const companionWishNote = fields.has('COMPANION_WISH')
? body.companion_wish_note?.trim() || null
: null;
const photo =
fields.has('PHOTO') && body.photo ? await fileToDataUrl(body.photo) : null;
if (paymentOption === null) {
// Pas de paiement en ligne (case désactivée, ou « je paierai sur
// place ») : rien à attendre, le bénévole est créé tout de suite.
const now = touch();
const volunteer: VolunteerRead = {
id: `v${state.seq++}`,
edition_id: state.edition.id,
first_name: titleCase(body.first_name.trim()),
last_name: titleCase(body.last_name.trim()),
email: fields.has('EMAIL') ? email : null,
phone: fields.has('PHONE') ? phone : null,
comment,
artist_ids: resolveWishes(state.artists, artistIds, 'artist.unknown'),
wished_position_ids: resolveWishes(state.positions, positionIds, 'position.unknown'),
wished_companion_ids: [],
has_photo: photo !== null,
companion_wish_note: companionWishNote,
self_declared_available: fields.has('AVAILABLE') ? available : false,
willing_to_pay_fee: fields.has('WILLING_TO_PAY_FEE'),
fee_payment_label: null,
fee_amount: null,
mecenat_address: null,
mecenat_city: null,
mecenat_postal_code: null,
mecenat_country: null,
fee_payment_status: null,
fee_paid_at: null,
created_at: now,
updated_at: now,
};
if (photo) state.photos.set(volunteer.id, photo);
state.volunteers.push(volunteer);
state.availability.set(volunteer.id, []);
return delay<FormSubmissionResult>({
volunteer_id: volunteer.id,
pending_token: null,
requires_mecenat_info: false,
sumup_checkout_url: null,
});
}
// Paiement en ligne choisi : rien en base tant que le paiement n'est
// pas confirmé — voir `PendingFormSubmission` côté back-end.
const pending: MockPendingSubmission = {
id: `pend${state.seq++}`,
editionId: state.edition.id,
firstName: titleCase(body.first_name.trim()),
lastName: titleCase(body.last_name.trim()),
email: fields.has('EMAIL') ? email : null,
phone: fields.has('PHONE') ? phone : null,
comment,
companionWishNote,
available: fields.has('AVAILABLE') ? available : false,
positionIds,
artistIds,
photo,
paymentLabel: paymentOption.label,
paymentAmount: paymentOption.amount,
requiresMecenatInfo: paymentOption.requires_mecenat_info,
mecenatAddress: null,
mecenatCity: null,
mecenatPostalCode: null,
mecenatCountry: null,
checkoutId: null,
status: null,
promotedVolunteerId: null,
pollCount: 0,
};
state.pendingSubmissions.set(pending.id, pending);
if (fields.has('PHOTO') && body.photo) {
volunteer.has_photo = true;
state.photos.set(volunteer.id, await fileToDataUrl(body.photo));
if (pending.requiresMecenatInfo) {
return delay<FormSubmissionResult>({
volunteer_id: null,
pending_token: pending.id,
requires_mecenat_info: true,
sumup_checkout_url: null,
});
}
// Simule la création d'un checkout SumUp (pas de vrai appel réseau
// en mode démonstration) — voir `app/api/forms.py:_create_fee_checkout`.
let checkoutUrl: string | null = null;
if (paymentOption && state.formTemplate.sumupMerchantCode && state.formTemplate.sumupApiKey) {
volunteer.fee_payment_label = paymentOption.label;
volunteer.fee_amount = paymentOption.amount;
volunteer.fee_payment_status = 'PENDING';
checkoutUrl = `https://checkout.sumup.com/pay/mock-${volunteer.id}`;
}
state.volunteers.push(volunteer);
state.availability.set(volunteer.id, []);
const checkoutUrl = createPendingCheckout(pending);
return delay<FormSubmissionResult>({
volunteer_id: volunteer.id,
volunteer_id: null,
pending_token: pending.id,
requires_mecenat_info: false,
sumup_checkout_url: checkoutUrl,
});
},
submitMecenatInfo: (_edition, token, body: MecenatInfoSubmit) => {
const pending = state.pendingSubmissions.get(token) ?? notFound();
if (pending.promotedVolunteerId !== null) {
throw new ApiError(422, 'form.pending_already_paid');
}
if (!pending.requiresMecenatInfo) {
throw new ApiError(422, 'form.pending_not_awaiting_info');
}
const address = body.address.trim();
const city = body.city.trim();
const postalCode = body.postal_code.trim();
const country = body.country.trim();
if (!address || !city || !postalCode || !country) {
throw new ApiError(422, 'import.row.missing_required_field');
}
pending.mecenatAddress = address;
pending.mecenatCity = city;
pending.mecenatPostalCode = postalCode;
pending.mecenatCountry = country;
const checkoutUrl = createPendingCheckout(pending);
return delay<FormSubmissionResult>({
volunteer_id: null,
pending_token: pending.id,
requires_mecenat_info: false,
sumup_checkout_url: checkoutUrl,
});
},
getPendingStatus: (_edition, token) => {
const pending = state.pendingSubmissions.get(token) ?? notFound();
// Simule le délai de confirmation réel (webhook asynchrone) : la
// première vérification reste PENDING, la suivante confirme.
if (pending.promotedVolunteerId === null && pending.checkoutId !== null) {
if (pending.pollCount === 0) {
pending.pollCount += 1;
} else {
promotePending(pending);
}
}
return delay<PendingSubmissionStatus>({
status: pending.promotedVolunteerId !== null ? 'PAID' : (pending.status ?? 'PENDING'),
label: pending.paymentLabel,
amount: pending.paymentAmount,
volunteer_id: pending.promotedVolunteerId,
});
},
retryPendingPayment: (_edition, token) => {
const pending = state.pendingSubmissions.get(token) ?? notFound();
if (pending.promotedVolunteerId !== null) {
throw new ApiError(422, 'form.pending_already_paid');
}
if (pending.requiresMecenatInfo && pending.mecenatAddress === null) {
throw new ApiError(422, 'form.pending_not_awaiting_info');
}
const checkoutUrl = createPendingCheckout(pending);
return delay<FormSubmissionResult>({
volunteer_id: null,
pending_token: pending.id,
requires_mecenat_info: false,
sumup_checkout_url: checkoutUrl,
});
},
+44 -5
View File
@@ -121,6 +121,12 @@ export interface VolunteerRead {
fee_amount: number | null;
fee_payment_status: FeePaymentStatus | null;
fee_paid_at: string | null;
/** Coordonnées pour un reçu fiscal (cerfa) — seulement si payé via un type
* de paiement « mécénat ». */
mecenat_address: string | null;
mecenat_city: string | null;
mecenat_postal_code: string | null;
mecenat_country: string | null;
created_at: string;
updated_at: string;
}
@@ -606,6 +612,9 @@ export interface FormPaymentOptionRead {
label: string;
amount: number;
order: number;
/** Type « mécénat » : le bénévole passe par une étape d'adresse postale
* avant le paiement (reçu fiscal / cerfa). */
requires_mecenat_info: boolean;
created_at: string;
updated_at: string;
}
@@ -614,12 +623,14 @@ export interface FormPaymentOptionCreate {
label: string;
amount: number;
order?: number;
requires_mecenat_info?: boolean;
}
export interface FormPaymentOptionUpdate {
label?: string;
amount?: number;
order?: number;
requires_mecenat_info?: boolean;
}
export interface PublicPositionOption {
@@ -639,6 +650,7 @@ export interface PublicPaymentOption {
id: string;
label: string;
amount: number;
requires_mecenat_info: boolean;
}
/** Tout ce qu'il faut pour afficher le formulaire public — jamais plus. */
@@ -655,12 +667,40 @@ export interface PublicFormConfig {
payment_options: PublicPaymentOption[];
}
/** Trois formes possibles, selon le choix de paiement (voir
* `app/api/forms.py:submit_public_form`) :
* - aucun paiement en ligne (case désactivée, ou « je paierai sur place ») :
* `volunteer_id` seul, le bénévole est déjà en base ;
* - paiement en ligne, type « mécénat » : `pending_token` +
* `requires_mecenat_info=true`, rien en base encore — direction l'étape
* d'adresse (`submitMecenatInfo`) ;
* - paiement en ligne, type simple : `pending_token` + `sumup_checkout_url`,
* direction la page de paiement SumUp. */
export interface FormSubmissionResult {
volunteer_id: string;
/** Lien de paiement hébergé SumUp, si un checkout a pu être créé. */
volunteer_id: string | null;
pending_token: string | null;
requires_mecenat_info: boolean;
sumup_checkout_url: string | null;
}
/** Étape d'adresse postale d'un type de paiement « mécénat » — tous
* obligatoires, nécessaires à l'édition d'un reçu fiscal (cerfa). */
export interface MecenatInfoSubmit {
address: string;
city: string;
postal_code: string;
country: string;
}
/** Réponse de la vérification en direct du paiement (page de confirmation) —
* interrogée jusqu'à ce que le statut ne soit plus `PENDING`. */
export interface PendingSubmissionStatus {
status: FeePaymentStatus;
label: string;
amount: number;
volunteer_id: string | null;
}
/** Champs du formulaire public — envoyés en `multipart/form-data` (photo
* possible). `position_wish_ids`/`artist_wish_ids` : JSON stringifié
* (`'["id1","id2"]'`), pas de tableau natif possible en multipart. */
@@ -673,9 +713,8 @@ export interface PublicFormSubmission {
comment?: string;
companion_wish_note?: string;
available?: boolean;
willing_to_pay_fee?: boolean;
/** Type de paiement choisi (voir `PublicPaymentOption`) — `undefined` =
* aucun (le bénévole ne participe pas à la cotisation). */
/** Type de paiement choisi (voir `PublicPaymentOption`) — absent = « je
* paierai sur place » (aucun paiement en ligne). */
payment_option_id?: string;
position_wish_ids?: string[];
artist_wish_ids?: string[];
+13
View File
@@ -37,6 +37,8 @@ import type {
LoginRequest,
MagicLinkRequest,
MatrixResponse,
MecenatInfoSubmit,
PendingSubmissionStatus,
PlanningNotificationRequest,
PlanningNotificationResult,
PositionCreate,
@@ -89,6 +91,8 @@ export interface PlanningApi {
planning(editionId: string, id: string): Promise<VolunteerPlanningEntry[]>;
/** Photo transmise via le formulaire public, `null` si absente. */
photoBlob(editionId: string, id: string): Promise<Blob | null>;
/** Export Excel de la liste des bénévoles (COORDINATION). */
exportXlsx(editionId: string): Promise<Blob>;
};
accounts: {
/** Comptes de connexion (mot de passe ou lien magique) de l'édition. */
@@ -187,6 +191,15 @@ export interface PlanningApi {
): Promise<FormPaymentOptionRead>;
remove(editionId: string, id: string): Promise<void>;
};
/** Inscription en attente de paiement en ligne — publics, aucune
* authentification (voir CLAUDE.md § 2, Paiement de la cotisation). */
submitMecenatInfo(
editionId: string,
token: string,
body: MecenatInfoSubmit,
): Promise<FormSubmissionResult>;
getPendingStatus(editionId: string, token: string): Promise<PendingSubmissionStatus>;
retryPendingPayment(editionId: string, token: string): Promise<FormSubmissionResult>;
};
positionSheets: {
/** Feuilles de poste PDF (une page par poste et par jour). */