Formulaire public : paiement de la cotisation via l'API SumUp (remplace Yapla)
ci-cd / build-backend (push) Failing after 4m12s
ci-cd / build-frontend (push) Skipped
ci-cd / deploy (push) Skipped

Remplace le simple lien Yapla statique par une vraie intégration API SumUp :
checkout hébergé créé à la soumission du formulaire, statut de paiement
(PENDING/PAID/FAILED/EXPIRED) tenu à jour par un webhook SumUp et visible sur
la fiche du bénévole. La cotisation n'est plus un montant unique : la
coordination configure autant de types de paiement qu'elle veut (libellé +
montant, ex. « Cotisation classique », « Mécénat »), et le bénévole en
choisit un à l'inscription — le libellé et le montant choisis sont figés sur
le bénévole, indépendamment d'une modification ultérieure de la config.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
AdamThomas
2026-09-29 19:56:04 +02:00
co-authored by Claude Sonnet 5
parent 0be5daa89c
commit 0bd38a6465
25 changed files with 1457 additions and 125 deletions
+40
View File
@@ -24,6 +24,8 @@ import type {
DayPartUpdate,
EditionUpdate,
FormFieldsSet,
FormPaymentOptionCreate,
FormPaymentOptionUpdate,
FormTemplateUpdate,
ImportMapping,
PlanningNotificationRequest,
@@ -633,6 +635,44 @@ export function useUploadFormLogo() {
});
}
/** Types de paiement de la cotisation (COORDINATION) — voir `FormPaymentOptionRead`. */
export function useFormPaymentOptions() {
return useQuery({
queryKey: ['formPaymentOptions', edition],
queryFn: () => api.forms.paymentOptions.list(edition),
});
}
function useInvalidateFormPaymentOptions() {
const client = useQueryClient();
return () => client.invalidateQueries({ queryKey: ['formPaymentOptions', edition] });
}
export function useCreateFormPaymentOption() {
const invalidate = useInvalidateFormPaymentOptions();
return useMutation({
mutationFn: (body: FormPaymentOptionCreate) => api.forms.paymentOptions.create(edition, body),
onSuccess: invalidate,
});
}
export function useUpdateFormPaymentOption() {
const invalidate = useInvalidateFormPaymentOptions();
return useMutation({
mutationFn: ({ id, body }: { id: string; body: FormPaymentOptionUpdate }) =>
api.forms.paymentOptions.update(edition, id, body),
onSuccess: invalidate,
});
}
export function useDeleteFormPaymentOption() {
const invalidate = useInvalidateFormPaymentOptions();
return useMutation({
mutationFn: (id: string) => api.forms.paymentOptions.remove(edition, id),
onSuccess: invalidate,
});
}
/** URL objet (aperçu) du logo du formulaire public, ou `null` si absent.
* Utilisable sans compte connecté (`GET .../form/logo` n'est pas protégé —
* affiché sur `/inscription`). `refreshKey` (ex. `dataUpdatedAt` de
+18
View File
@@ -14,6 +14,8 @@ import type {
DayPartUpdate,
EditionUpdate,
FormFieldsSet,
FormPaymentOptionCreate,
FormPaymentOptionUpdate,
FormTemplateUpdate,
ImportMapping,
LoginRequest,
@@ -296,6 +298,7 @@ export function createHttpApi(baseUrl: string): PlanningApi {
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));
}
@@ -305,6 +308,21 @@ export function createHttpApi(baseUrl: string): PlanningApi {
if (body.photo) form.append('photo', body.photo);
return request(`${base(editionId)}/form/public/submit`, { method: 'POST', body: form });
},
paymentOptions: {
list: (editionId) => request(`${base(editionId)}/form/payment-options`),
create: (editionId, body: FormPaymentOptionCreate) =>
request(`${base(editionId)}/form/payment-options`, {
method: 'POST',
body: JSON.stringify(body),
}),
update: (editionId, id, body: FormPaymentOptionUpdate) =>
request(`${base(editionId)}/form/payment-options/${id}`, {
method: 'PATCH',
body: JSON.stringify(body),
}),
remove: (editionId, id) =>
request(`${base(editionId)}/form/payment-options/${id}`, { method: 'DELETE' }),
},
},
positionSheets: {
generate: async (editionId, body: PositionSheetsRequest) => {
+11 -2
View File
@@ -9,6 +9,7 @@ import type {
DayPartRead,
EditionRead,
FormFieldType,
FormPaymentOptionRead,
PositionCategory,
PositionRead,
ShiftRead,
@@ -56,8 +57,10 @@ export interface MockState {
logo: string | null; // data URL
logoType: string | null;
description: string | null;
yaplaUrl: string | null;
sumupMerchantCode: string | null;
sumupApiKey: string | null;
};
formPaymentOptions: FormPaymentOptionRead[];
/** volunteerId -> photo (formulaire public), data URL. */
photos: Map<string, string>;
seq: number;
@@ -273,6 +276,10 @@ export function buildMockState(): MockState {
companion_wish_note: null,
self_declared_available: false,
willing_to_pay_fee: false,
fee_payment_label: null,
fee_amount: null,
fee_payment_status: null,
fee_paid_at: null,
created_at: NOW,
updated_at: NOW,
});
@@ -447,8 +454,10 @@ export function buildMockState(): MockState {
fields: [],
},
formTemplate: {
isOpen: true, fields: [], logo: null, logoType: null, description: null, yaplaUrl: null,
isOpen: true, fields: [], logo: null, logoType: null, description: null,
sumupMerchantCode: null, sumupApiKey: null,
},
formPaymentOptions: [],
photos: new Map(),
seq,
};
+107 -9
View File
@@ -20,6 +20,9 @@ import type {
DayPartUpdate,
EditionUpdate,
FormFieldsSet,
FormPaymentOptionCreate,
FormPaymentOptionRead,
FormPaymentOptionUpdate,
FormSubmissionResult,
FormTemplateRead,
FormTemplateUpdate,
@@ -122,15 +125,26 @@ const fileToDataUrl = (file: File): Promise<string> =>
reader.readAsDataURL(file);
});
const sumupEnabled = (): boolean =>
Boolean(
state.formTemplate.sumupMerchantCode &&
state.formTemplate.sumupApiKey &&
state.formPaymentOptions.length > 0,
);
const formTemplateRead = (): FormTemplateRead => ({
edition_id: state.edition.id,
is_open: state.formTemplate.isOpen,
fields: state.formTemplate.fields,
has_logo: state.formTemplate.logo !== null,
description: state.formTemplate.description,
yapla_url: state.formTemplate.yaplaUrl,
sumup_merchant_code: state.formTemplate.sumupMerchantCode,
sumup_api_key_set: state.formTemplate.sumupApiKey !== null,
});
const sortedPaymentOptions = (): FormPaymentOptionRead[] =>
[...state.formPaymentOptions].sort((a, b) => a.order - b.order || a.label.localeCompare(b.label));
// PDF valide minimal (une page blanche) — remplace la vraie génération
// (réservée au back-end réel) pour que le téléchargement fonctionne en mode
// démonstration.
@@ -856,6 +870,10 @@ export function createMockApi(): PlanningApi {
companion_wish_note: null,
self_declared_available: false,
willing_to_pay_fee: false,
fee_payment_label: null,
fee_amount: null,
fee_payment_status: null,
fee_paid_at: null,
created_at: now,
updated_at: now,
};
@@ -1191,6 +1209,10 @@ export function createMockApi(): PlanningApi {
companion_wish_note: null,
self_declared_available: false,
willing_to_pay_fee: false,
fee_payment_label: null,
fee_amount: null,
fee_payment_status: null,
fee_paid_at: null,
created_at: now,
updated_at: now,
};
@@ -1484,11 +1506,11 @@ export function createMockApi(): PlanningApi {
setOpen: (_edition, body: FormTemplateUpdate) => {
if (body.is_open !== undefined) state.formTemplate.isOpen = body.is_open;
if (body.description !== undefined) state.formTemplate.description = body.description;
if (body.yapla_url !== undefined) {
if (body.yapla_url && !/^https?:\/\//.test(body.yapla_url)) {
throw new ApiError(422, 'form.invalid_yapla_url');
}
state.formTemplate.yaplaUrl = body.yapla_url;
if (body.sumup_merchant_code !== undefined) {
state.formTemplate.sumupMerchantCode = body.sumup_merchant_code;
}
if (body.sumup_api_key !== undefined) {
state.formTemplate.sumupApiKey = body.sumup_api_key || null;
}
return delay<FormTemplateRead>(formTemplateRead());
},
@@ -1518,9 +1540,62 @@ export function createMockApi(): PlanningApi {
: [],
has_logo: state.formTemplate.logo !== null,
description: state.formTemplate.description,
yapla_url: state.formTemplate.yaplaUrl,
sumup_enabled: sumupEnabled(),
payment_options: sortedPaymentOptions().map((o) => ({
id: o.id,
label: o.label,
amount: o.amount,
})),
});
},
paymentOptions: {
list: (): Promise<FormPaymentOptionRead[]> => delay(sortedPaymentOptions()),
create: (_edition, body: FormPaymentOptionCreate) => {
if (body.amount <= 0) throw new ApiError(422, 'form.invalid_sumup_amount');
const label = body.label.trim();
if (state.formPaymentOptions.some((o) => o.label === label)) {
throw new ApiError(409, 'form.duplicate_payment_option_label');
}
const now = touch();
const option: FormPaymentOptionRead = {
id: `pay${state.seq++}`,
edition_id: state.edition.id,
label,
amount: body.amount,
order: body.order ?? 0,
created_at: now,
updated_at: now,
};
state.formPaymentOptions.push(option);
return delay<FormPaymentOptionRead>(option);
},
update: (_edition, id, body: FormPaymentOptionUpdate) => {
const option = state.formPaymentOptions.find((o) => o.id === id) ?? notFound();
if (body.amount !== undefined && body.amount <= 0) {
throw new ApiError(422, 'form.invalid_sumup_amount');
}
const label = body.label !== undefined ? body.label.trim() : option.label;
if (
label !== option.label &&
state.formPaymentOptions.some((o) => o.id !== id && o.label === label)
) {
throw new ApiError(409, 'form.duplicate_payment_option_label');
}
Object.assign(option, {
label,
...(body.amount !== undefined ? { amount: body.amount } : {}),
...(body.order !== undefined ? { order: body.order } : {}),
updated_at: touch(),
});
return delay<FormPaymentOptionRead>(option);
},
remove: (_edition, id) => {
const index = state.formPaymentOptions.findIndex((o) => o.id === id);
if (index === -1) notFound();
state.formPaymentOptions.splice(index, 1);
return delay(undefined);
},
},
submitPublic: async (_edition, body: PublicFormSubmission) => {
if (!state.formTemplate.isOpen) throw new ApiError(422, 'form.closed');
if (!body.hcaptcha_token) throw new ApiError(422, 'form.captcha_failed');
@@ -1539,6 +1614,12 @@ export function createMockApi(): PlanningApi {
throw new ApiError(422, 'form.too_many_artists_same_day');
}
let paymentOption: FormPaymentOptionRead | null = null;
if (fields.has('WILLING_TO_PAY_FEE') && body.payment_option_id) {
paymentOption = state.formPaymentOptions.find((o) => o.id === body.payment_option_id) ?? null;
if (!paymentOption) throw new ApiError(422, 'form.unknown_payment_option');
}
const now = touch();
const volunteer: VolunteerRead = {
id: `v${state.seq++}`,
@@ -1557,8 +1638,12 @@ export function createMockApi(): PlanningApi {
: 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)
? 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,
};
@@ -1568,9 +1653,22 @@ export function createMockApi(): PlanningApi {
state.photos.set(volunteer.id, await fileToDataUrl(body.photo));
}
// 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, []);
return delay<FormSubmissionResult>({ volunteer_id: volunteer.id });
return delay<FormSubmissionResult>({
volunteer_id: volunteer.id,
sumup_checkout_url: checkoutUrl,
});
},
},
+59 -4
View File
@@ -113,10 +113,20 @@ export interface VolunteerRead {
companion_wish_note: string | null;
self_declared_available: boolean;
willing_to_pay_fee: boolean;
/** Paiement de la cotisation via SumUp (voir schema forms) —
* `fee_payment_label`/`fee_amount` : copie figée du type de paiement
* choisi (pas la config actuelle). `fee_payment_status` reste `null` tant
* qu'aucun checkout n'a jamais été créé pour ce bénévole. */
fee_payment_label: string | null;
fee_amount: number | null;
fee_payment_status: FeePaymentStatus | null;
fee_paid_at: string | null;
created_at: string;
updated_at: string;
}
export type FeePaymentStatus = 'PENDING' | 'PAID' | 'FAILED' | 'EXPIRED';
export interface VolunteerCreate {
first_name: string;
last_name: string;
@@ -573,14 +583,43 @@ export interface FormTemplateRead {
fields: FormFieldType[];
has_logo: boolean;
description: string | null;
yapla_url: string | null;
sumup_merchant_code: string | null;
/** `sumup_api_key` (secret) n'est jamais renvoyé — seulement s'il est défini. */
sumup_api_key_set: boolean;
}
/** `undefined`/absent = inchangé pour chaque champ. */
/** `undefined`/absent = inchangé pour chaque champ. Chaîne vide sur
* `sumup_api_key` efface le secret enregistré. */
export interface FormTemplateUpdate {
is_open?: boolean;
description?: string | null;
yapla_url?: string | null;
sumup_merchant_code?: string | null;
sumup_api_key?: string | null;
}
/** Un type de paiement proposé sur le formulaire public (ex. « Cotisation
* classique », « Mécénat ») — libellé + montant fixe, configurés librement
* par la coordination (comme les créneaux types `DayPart`). */
export interface FormPaymentOptionRead {
id: string;
edition_id: string;
label: string;
amount: number;
order: number;
created_at: string;
updated_at: string;
}
export interface FormPaymentOptionCreate {
label: string;
amount: number;
order?: number;
}
export interface FormPaymentOptionUpdate {
label?: string;
amount?: number;
order?: number;
}
export interface PublicPositionOption {
@@ -594,6 +633,14 @@ export interface PublicArtistOption {
performance_date: string;
}
/** Type de paiement affiché sur le formulaire public — un montant n'est pas
* un secret, contrairement aux identifiants SumUp. */
export interface PublicPaymentOption {
id: string;
label: string;
amount: number;
}
/** Tout ce qu'il faut pour afficher le formulaire public — jamais plus. */
export interface PublicFormConfig {
is_open: boolean;
@@ -602,11 +649,16 @@ export interface PublicFormConfig {
artists: PublicArtistOption[];
has_logo: boolean;
description: string | null;
yapla_url: string | null;
/** `true` si l'édition a configuré SumUp (identifiants + au moins un type
* de paiement). */
sumup_enabled: boolean;
payment_options: PublicPaymentOption[];
}
export interface FormSubmissionResult {
volunteer_id: string;
/** Lien de paiement hébergé SumUp, si un checkout a pu être créé. */
sumup_checkout_url: string | null;
}
/** Champs du formulaire public — envoyés en `multipart/form-data` (photo
@@ -622,6 +674,9 @@ export interface PublicFormSubmission {
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). */
payment_option_id?: string;
position_wish_ids?: string[];
artist_wish_ids?: string[];
photo?: File | null;
+14
View File
@@ -24,6 +24,9 @@ import type {
EditionRead,
EditionUpdate,
FormFieldsSet,
FormPaymentOptionCreate,
FormPaymentOptionRead,
FormPaymentOptionUpdate,
FormSubmissionResult,
FormTemplateRead,
FormTemplateUpdate,
@@ -173,6 +176,17 @@ export interface PlanningApi {
getPublicConfig(editionId: string): Promise<PublicFormConfig>;
submitPublic(editionId: string, body: PublicFormSubmission): Promise<FormSubmissionResult>;
logoBlob(editionId: string): Promise<Blob | null>;
/** Types de paiement de la cotisation (COORDINATION uniquement). */
paymentOptions: {
list(editionId: string): Promise<FormPaymentOptionRead[]>;
create(editionId: string, body: FormPaymentOptionCreate): Promise<FormPaymentOptionRead>;
update(
editionId: string,
id: string,
body: FormPaymentOptionUpdate,
): Promise<FormPaymentOptionRead>;
remove(editionId: string, id: string): Promise<void>;
};
};
positionSheets: {
/** Feuilles de poste PDF (une page par poste et par jour). */