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
@@ -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,
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user