Formulaire public : paiement de la cotisation via l'API SumUp (remplace Yapla)
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:
co-authored by
Claude Sonnet 5
parent
0be5daa89c
commit
0bd38a6465
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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). */
|
||||
|
||||
Reference in New Issue
Block a user