From 15f65b10149b2653b7a083496d12b6cd1afa2330 Mon Sep 17 00:00:00 2001 From: Thibaut Valentin Date: Thu, 14 Aug 2025 22:45:03 +0200 Subject: [PATCH] feat: HelloAsso payment --- .../ffsaf/data/model/CheckoutModel.java | 41 +++++ .../data/repository/CheckoutRepository.java | 9 + .../ffsaf/domain/service/CheckoutService.java | 161 +++++++++++++++++ .../domain/service/HelloAssoTokenService.java | 68 ++++++++ .../ffsaf/domain/service/LicenceService.java | 43 ++++- .../ffsaf/domain/service/LoggerService.java | 12 ++ .../ffsaf/domain/service/WebhookService.java | 28 +++ .../ffsaf/rest/LicenceEndpoints.java | 18 +- .../ffsaf/rest/WebhookEndpoints.java | 53 ++++++ .../rest/client/HelloAssoAuthClient.java | 32 ++++ .../rest/client/HelloAssoHeadersFactory.java | 24 +++ .../ffsaf/rest/client/HelloAssoService.java | 51 ++++++ .../ffsaf/rest/client/dto/ApiError.java | 14 ++ .../client/dto/CheckoutIntentsRequest.java | 30 ++++ .../client/dto/CheckoutIntentsResponse.java | 11 ++ .../rest/client/dto/CheckoutMetadata.java | 12 ++ .../client/dto/HelloassoNotification.java | 16 ++ .../rest/client/dto/NotificationData.java | 30 ++++ .../ffsaf/rest/client/dto/TokenResponse.java | 30 ++++ src/main/resources/application.properties | 12 +- src/main/webapp/src/pages/MemberList.jsx | 3 + .../webapp/src/pages/PayAndValidateList.css | 112 ++++++++++++ ...alidateList.jsx => PayAndValidateList.jsx} | 162 ++++++++++++++++-- src/main/webapp/src/pages/admin/AdminRoot.jsx | 4 +- src/main/webapp/src/pages/club/ClubRoot.jsx | 17 +- .../webapp/src/pages/club/PaymentError.jsx | 19 ++ .../webapp/src/pages/club/PaymentReturn.jsx | 16 ++ src/main/webapp/src/utils/Tools.js | 6 +- 28 files changed, 1003 insertions(+), 31 deletions(-) create mode 100644 src/main/java/fr/titionfire/ffsaf/data/model/CheckoutModel.java create mode 100644 src/main/java/fr/titionfire/ffsaf/data/repository/CheckoutRepository.java create mode 100644 src/main/java/fr/titionfire/ffsaf/domain/service/CheckoutService.java create mode 100644 src/main/java/fr/titionfire/ffsaf/domain/service/HelloAssoTokenService.java create mode 100644 src/main/java/fr/titionfire/ffsaf/domain/service/WebhookService.java create mode 100644 src/main/java/fr/titionfire/ffsaf/rest/WebhookEndpoints.java create mode 100644 src/main/java/fr/titionfire/ffsaf/rest/client/HelloAssoAuthClient.java create mode 100644 src/main/java/fr/titionfire/ffsaf/rest/client/HelloAssoHeadersFactory.java create mode 100644 src/main/java/fr/titionfire/ffsaf/rest/client/HelloAssoService.java create mode 100644 src/main/java/fr/titionfire/ffsaf/rest/client/dto/ApiError.java create mode 100644 src/main/java/fr/titionfire/ffsaf/rest/client/dto/CheckoutIntentsRequest.java create mode 100644 src/main/java/fr/titionfire/ffsaf/rest/client/dto/CheckoutIntentsResponse.java create mode 100644 src/main/java/fr/titionfire/ffsaf/rest/client/dto/CheckoutMetadata.java create mode 100644 src/main/java/fr/titionfire/ffsaf/rest/client/dto/HelloassoNotification.java create mode 100644 src/main/java/fr/titionfire/ffsaf/rest/client/dto/NotificationData.java create mode 100644 src/main/java/fr/titionfire/ffsaf/rest/client/dto/TokenResponse.java create mode 100644 src/main/webapp/src/pages/PayAndValidateList.css rename src/main/webapp/src/pages/{ValidateList.jsx => PayAndValidateList.jsx} (54%) create mode 100644 src/main/webapp/src/pages/club/PaymentError.jsx create mode 100644 src/main/webapp/src/pages/club/PaymentReturn.jsx diff --git a/src/main/java/fr/titionfire/ffsaf/data/model/CheckoutModel.java b/src/main/java/fr/titionfire/ffsaf/data/model/CheckoutModel.java new file mode 100644 index 0000000..0308260 --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/data/model/CheckoutModel.java @@ -0,0 +1,41 @@ +package fr.titionfire.ffsaf.data.model; + +import io.quarkus.runtime.annotations.RegisterForReflection; +import jakarta.persistence.*; +import lombok.*; +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +import java.util.Date; +import java.util.List; + +@Getter +@Setter +@Builder +@AllArgsConstructor +@NoArgsConstructor +@RegisterForReflection + +@Entity +@Table(name = "checkout") +public class CheckoutModel { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Schema(description = "Identifiant du checkout", example = "42") + Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "membre", referencedColumnName = "id") + MembreModel membre; + + Date creationDate = new Date(); + + List licenseIds; + + Integer checkoutId; + + PaymentStatus paymentStatus; + + public enum PaymentStatus { + PENDING, AUTHORIZED, REFUSED, UNKNOW, REGISTERED, REFUNDING, REFUNDED, CONTESTED + } +} diff --git a/src/main/java/fr/titionfire/ffsaf/data/repository/CheckoutRepository.java b/src/main/java/fr/titionfire/ffsaf/data/repository/CheckoutRepository.java new file mode 100644 index 0000000..6a4d68d --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/data/repository/CheckoutRepository.java @@ -0,0 +1,9 @@ +package fr.titionfire.ffsaf.data.repository; + +import fr.titionfire.ffsaf.data.model.CheckoutModel; +import io.quarkus.hibernate.reactive.panache.PanacheRepositoryBase; +import jakarta.enterprise.context.ApplicationScoped; + +@ApplicationScoped +public class CheckoutRepository implements PanacheRepositoryBase { +} diff --git a/src/main/java/fr/titionfire/ffsaf/domain/service/CheckoutService.java b/src/main/java/fr/titionfire/ffsaf/domain/service/CheckoutService.java new file mode 100644 index 0000000..20de61a --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/domain/service/CheckoutService.java @@ -0,0 +1,161 @@ +package fr.titionfire.ffsaf.domain.service; + +import fr.titionfire.ffsaf.data.model.CheckoutModel; +import fr.titionfire.ffsaf.data.model.LogModel; +import fr.titionfire.ffsaf.data.repository.CheckoutRepository; +import fr.titionfire.ffsaf.data.repository.LicenceRepository; +import fr.titionfire.ffsaf.rest.client.HelloAssoService; +import fr.titionfire.ffsaf.rest.client.dto.CheckoutIntentsRequest; +import fr.titionfire.ffsaf.rest.client.dto.CheckoutIntentsResponse; +import fr.titionfire.ffsaf.rest.client.dto.CheckoutMetadata; +import fr.titionfire.ffsaf.rest.exception.DInternalError; +import fr.titionfire.ffsaf.utils.SecurityCtx; +import fr.titionfire.ffsaf.utils.Utils; +import io.quarkus.hibernate.reactive.panache.Panache; +import io.quarkus.hibernate.reactive.panache.common.WithSession; +import io.smallrye.mutiny.Uni; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.ws.rs.core.Response; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.eclipse.microprofile.rest.client.inject.RestClient; +import org.hibernate.reactive.mutiny.Mutiny; + +import java.util.List; + +@WithSession +@ApplicationScoped +public class CheckoutService { + + @Inject + CheckoutRepository repository; + + @Inject + LicenceRepository licenceRepository; + + @Inject + MembreService membreService; + + @Inject + LicenceService licenceService; + + @Inject + LoggerService ls; + + @RestClient + HelloAssoService helloAssoService; + + @ConfigProperty(name = "frontRootUrl") + String frontRootUrl; + + @ConfigProperty(name = "unitLicencePrice") + int unitLicencePrice; + + @ConfigProperty(name = "helloasso.organizationSlug") + String organizationSlug; + + public Uni canDeleteLicence(long id) { + return repository.find("?1 IN licenseIds", id).count().map(count -> count == 0); + } + + public Uni create(List ids, SecurityCtx securityCtx) { + return membreService.getByAccountId(securityCtx.getSubject()) + .call(membreModel -> Mutiny.fetch(membreModel.getClub())) + .chain(membreModel -> { + CheckoutModel model = new CheckoutModel(); + model.setMembre(membreModel); + model.setLicenseIds(ids); + model.setPaymentStatus(CheckoutModel.PaymentStatus.UNKNOW); + + return Panache.withTransaction(() -> repository.persist(model)); + }) + .chain(checkoutModel -> { + CheckoutIntentsRequest request = new CheckoutIntentsRequest(); + request.setTotalAmount(unitLicencePrice * checkoutModel.getLicenseIds().size()); + request.setInitialAmount(unitLicencePrice * checkoutModel.getLicenseIds().size()); + request.setItemName("%d licences %d-%d pour %s".formatted(checkoutModel.getLicenseIds().size(), + Utils.getSaison(), Utils.getSaison() + 1, checkoutModel.getMembre().getClub().getName())); + request.setBackUrl(frontRootUrl + "/club/member/pay"); + request.setErrorUrl(frontRootUrl + "/club/member/pay/error"); + request.setReturnUrl(frontRootUrl + "/club/member/pay/return"); + request.setContainsDonation(false); + request.setPayer(new CheckoutIntentsRequest.Payer(checkoutModel.getMembre().getFname(), + checkoutModel.getMembre().getLname(), checkoutModel.getMembre().getEmail())); + request.setMetadata(new CheckoutMetadata(checkoutModel.getId())); + + return helloAssoService.checkout(organizationSlug, request) + .call(response -> { + checkoutModel.setCheckoutId(response.getId()); + return Panache.withTransaction(() -> repository.persist(checkoutModel)); + }); + }) + .onFailure().transform(t -> new DInternalError(t.getMessage())) + .map(CheckoutIntentsResponse::getRedirectUrl); + } + + public Uni paymentStatusChange(String state, CheckoutMetadata metadata) { + return repository.findById(metadata.getCheckoutDBId()) + .chain(checkoutModel -> { + CheckoutModel.PaymentStatus newStatus = CheckoutModel.PaymentStatus.valueOf(state.toUpperCase()); + + Uni uni = Uni.createFrom().nullItem(); + + if (checkoutModel.getPaymentStatus().equals(newStatus)) + return uni; + + if (newStatus.equals(CheckoutModel.PaymentStatus.AUTHORIZED)) { + for (Long id : checkoutModel.getLicenseIds()) { + uni = uni.chain(__ -> licenceRepository.findById(id) + .onFailure().recoverWithNull() + .call(licenceModel -> { + if (licenceModel == null) { + ls.logAnonymous(LogModel.ActionType.UPDATE, LogModel.ObjectType.Licence, + "Fail to save payment for licence (checkout n°" + checkoutModel.getCheckoutId() + ")", + "", id); + return Uni.createFrom().nullItem(); + } + + ls.logUpdateAnonymous("Paiement de la licence", licenceModel); + licenceModel.setPay(true); + + if (licenceModel.getCertificate() != null && licenceModel.getCertificate() + .length() > 3) { + if (!licenceModel.isValidate()) + ls.logUpdateAnonymous("Validation automatique de la licence", + licenceModel); + return licenceService.validateLicences(licenceModel); + } else { + return Panache.withTransaction( + () -> licenceRepository.persist(licenceModel)); + } + })); + } + } else if (checkoutModel.getPaymentStatus().equals(CheckoutModel.PaymentStatus.AUTHORIZED)) { + for (Long id : checkoutModel.getLicenseIds()) { + uni = uni.chain(__ -> licenceRepository.findById(id) + .onFailure().recoverWithNull() + .call(licenceModel -> { + if (licenceModel == null) + return Uni.createFrom().nullItem(); + + ls.logUpdateAnonymous("Annulation automatique du paiement de la licence", + licenceModel); + licenceModel.setPay(false); + if (licenceModel.isValidate()) + ls.logUpdateAnonymous( + "Annulation automatique de la validation de la licence", + licenceModel); + licenceModel.setValidate(false); + return Panache.withTransaction(() -> licenceRepository.persist(licenceModel)); + })); + } + } + uni = uni.call(__ -> ls.append()); + + checkoutModel.setPaymentStatus(newStatus); + return uni.chain(__ -> Panache.withTransaction(() -> repository.persist(checkoutModel))); + }) + .onFailure().invoke(Throwable::printStackTrace) + .map(__ -> Response.ok().build()); + } +} diff --git a/src/main/java/fr/titionfire/ffsaf/domain/service/HelloAssoTokenService.java b/src/main/java/fr/titionfire/ffsaf/domain/service/HelloAssoTokenService.java new file mode 100644 index 0000000..253a071 --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/domain/service/HelloAssoTokenService.java @@ -0,0 +1,68 @@ +package fr.titionfire.ffsaf.domain.service; + +import fr.titionfire.ffsaf.rest.client.HelloAssoAuthClient; +import fr.titionfire.ffsaf.rest.client.dto.TokenResponse; +import io.smallrye.mutiny.Uni; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.eclipse.microprofile.rest.client.inject.RestClient; +import org.jboss.logging.Logger; + +@ApplicationScoped +public class HelloAssoTokenService { + private static final Logger LOG = Logger.getLogger(HelloAssoTokenService.class); + + @Inject + @RestClient + HelloAssoAuthClient authClient; + + @ConfigProperty(name = "helloasso.client-id") + String clientId; + + @ConfigProperty(name = "helloasso.client-secret") + String clientSecret; + + private TokenResponse currentToken; // Stockage en mémoire (pour un seul pod) + + // Récupère un token valide (en le rafraîchissant si nécessaire) + public Uni getValidAccessToken() { + if (currentToken == null || currentToken.isExpired()) { + return fetchNewToken(clientId, clientSecret); + } + return Uni.createFrom().item(currentToken.accessToken); + } + + // Récupère un nouveau token (via client_credentials ou refresh_token) + private Uni fetchNewToken(String clientId, String clientSecret) { + if (currentToken != null && currentToken.refreshToken != null) { + // On utilise le refresh_token si disponible + return authClient.refreshToken("refresh_token", clientId, currentToken.refreshToken) + .onItem().invoke(token -> { + LOG.info("Token rafraîchi avec succès"); + currentToken = token; + }) + .onFailure().recoverWithItem(e -> { + LOG.warn("Échec du rafraîchissement, utilisation des credentials", e); + return null; // Force l'utilisation des credentials + }) + .flatMap(token -> token != null ? + Uni.createFrom().item(token.accessToken) : + getTokenWithCredentials(clientId, clientSecret) + ); + } else { + return getTokenWithCredentials(clientId, clientSecret); + } + } + + // Récupère un token avec client_id/client_secret + private Uni getTokenWithCredentials(String clientId, String clientSecret) { + return authClient.getToken("client_credentials", clientId, clientSecret) + .onItem().invoke(token -> { + LOG.info("Nouveau token obtenu"); + currentToken = token; + }) + .onFailure().invoke(e -> LOG.error("Erreur lors de l'obtention du token", e)) + .map(token -> token.accessToken); + } +} diff --git a/src/main/java/fr/titionfire/ffsaf/domain/service/LicenceService.java b/src/main/java/fr/titionfire/ffsaf/domain/service/LicenceService.java index 9a061a7..2269249 100644 --- a/src/main/java/fr/titionfire/ffsaf/domain/service/LicenceService.java +++ b/src/main/java/fr/titionfire/ffsaf/domain/service/LicenceService.java @@ -42,6 +42,9 @@ public class LicenceService { @Inject LoggerService ls; + @Inject + CheckoutService checkoutService; + public Uni> getLicence(long id, Consumer checkPerm) { return combRepository.findById(id).invoke(checkPerm) .chain(combRepository -> Mutiny.fetch(combRepository.getLicences())); @@ -64,17 +67,21 @@ public class LicenceService { .chain(model -> { if (!model.isValidate()) ls.logUpdate("validation de la licence", model); - model.setValidate(true); - return Panache.withTransaction(() -> repository.persist(model) - .call(m -> Mutiny.fetch(m.getMembre()) - .call(genLicenceNumberAndAccountIfNeed()) - )); + return validateLicences(model); })) .map(__ -> "OK"); } return uni.call(__ -> ls.append()); } + protected Uni validateLicences(LicenceModel model) { + model.setValidate(true); + return Panache.withTransaction(() -> repository.persist(model) + .call(m -> Mutiny.fetch(m.getMembre()) + .call(genLicenceNumberAndAccountIfNeed()) + )); + } + public Uni setLicence(long id, LicenceForm form) { if (form.getId() == -1) { return combRepository.findById(id).chain(membreModel -> { @@ -122,8 +129,29 @@ public class LicenceService { : Uni.createFrom().nullItem()); } + public Uni payLicences(List ids, Consumer checkPerm, SecurityCtx securityCtx) { + return repository.list("membre.id IN ?1 AND saison = ?2 AND pay = FALSE", ids, Utils.getSaison()) + .invoke(Unchecked.consumer(models -> { + if (models.size() != ids.size()) + throw new DBadRequestException("Erreur lors de la sélection des membres"); + })) + .call(models -> { + Uni uni = Uni.createFrom().nullItem(); + for (LicenceModel model : models) + uni = uni.chain(__ -> Mutiny.fetch(model.getMembre()).invoke(checkPerm)); + return uni; + }) + .chain(models -> checkoutService.create(models.stream().map(LicenceModel::getId).toList(), + securityCtx)); + } + public Uni deleteLicence(long id) { return repository.findById(id) + .call(__ -> checkoutService.canDeleteLicence(id) + .invoke(Unchecked.consumer(b -> { + if (!b) throw new DBadRequestException( + "Impossible de supprimer une licence pour laquelle un paiement est en cours"); + }))) .call(model -> ls.logADelete(model)) .chain(model -> repository.delete(model)); } @@ -160,6 +188,11 @@ public class LicenceService { public Uni deleteAskLicence(long id, Consumer checkPerm) { return repository.findById(id) .call(licenceModel -> Mutiny.fetch(licenceModel.getMembre()).invoke(checkPerm)) + .call(__ -> checkoutService.canDeleteLicence(id) + .invoke(Unchecked.consumer(b -> { + if (!b) throw new DBadRequestException( + "Impossible de supprimer une licence pour laquelle un paiement est en cours"); + }))) .invoke(Unchecked.consumer(licenceModel -> { if (licenceModel.isValidate()) throw new DBadRequestException("Impossible de supprimer une licence déjà validée"); diff --git a/src/main/java/fr/titionfire/ffsaf/domain/service/LoggerService.java b/src/main/java/fr/titionfire/ffsaf/domain/service/LoggerService.java index 89a8b06..025c88b 100644 --- a/src/main/java/fr/titionfire/ffsaf/domain/service/LoggerService.java +++ b/src/main/java/fr/titionfire/ffsaf/domain/service/LoggerService.java @@ -71,10 +71,18 @@ public class LoggerService { message)); } + public void logAnonymous(ActionType action, ObjectType object, String message, String target_name, Long target_id) { + buffer.add(new LogModel(null, null, new Date(), action, object, target_id, target_name, message)); + } + public void log(ActionType action, String message, LoggableModel model) { log(action, model.getObjectType(), message, model.getObjectName(), model.getId()); } + public void logAnonymous(ActionType action, String message, LoggableModel model) { + logAnonymous(action, model.getObjectType(), message, model.getObjectName(), model.getId()); + } + public void logAdd(LoggableModel model) { log(ActionType.ADD, "", model); } @@ -83,6 +91,10 @@ public class LoggerService { log(ActionType.UPDATE, message, model); } + public void logUpdateAnonymous(String message, LoggableModel model) { + logAnonymous(ActionType.UPDATE, message, model); + } + public void logChange(String champ, Object o1, Object o2, LoggableModel model) { if (Objects.equals(o1, o2)) return; diff --git a/src/main/java/fr/titionfire/ffsaf/domain/service/WebhookService.java b/src/main/java/fr/titionfire/ffsaf/domain/service/WebhookService.java new file mode 100644 index 0000000..9e011bc --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/domain/service/WebhookService.java @@ -0,0 +1,28 @@ +package fr.titionfire.ffsaf.domain.service; + +import fr.titionfire.ffsaf.rest.client.dto.HelloassoNotification; +import io.smallrye.mutiny.Uni; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.ws.rs.core.Response; +import org.eclipse.microprofile.config.inject.ConfigProperty; + +@ApplicationScoped +public class WebhookService { + + @Inject + CheckoutService checkoutService; + + @ConfigProperty(name = "helloasso.organizationSlug") + String organizationSlug; + + public Uni helloAssoNotification(HelloassoNotification notification) { + if (notification.getEventType().equals("Payment")){ + if (notification.getData().getOrder().getOrganizationSlug().equalsIgnoreCase(organizationSlug)){ + return checkoutService.paymentStatusChange(notification.getData().getState(), notification.getMetadata()); + } + } + + return Uni.createFrom().item(Response.ok().build()); + } +} diff --git a/src/main/java/fr/titionfire/ffsaf/rest/LicenceEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/LicenceEndpoints.java index 448995b..9264b2d 100644 --- a/src/main/java/fr/titionfire/ffsaf/rest/LicenceEndpoints.java +++ b/src/main/java/fr/titionfire/ffsaf/rest/LicenceEndpoints.java @@ -81,6 +81,21 @@ public class LicenceEndpoints { .map(licenceModels -> licenceModels.stream().map(SimpleLicence::fromModel).toList()); } + @POST + @Path("pay") + @RolesAllowed({"club_president", "club_secretaire", "club_respo_intra"}) + @Produces(MediaType.APPLICATION_JSON) + @Consumes(MediaType.APPLICATION_JSON) + @Operation(summary = "Paiement des licence", description = "Retourne le lien de paiement pour les licence des membre fournie") + @APIResponses(value = { + @APIResponse(responseCode = "200", description = "Commande avec succès"), + @APIResponse(responseCode = "403", description = "Accès refusé"), + @APIResponse(responseCode = "500", description = "Erreur interne du serveur") + }) + public Uni payLicences(@Parameter(description = "Id des membres") List ids) { + return licenceService.payLicences(ids, checkPerm, securityCtx); + } + @POST @Path("{id}") @RolesAllowed("federation_admin") @@ -98,7 +113,6 @@ public class LicenceEndpoints { return licenceService.setLicence(id, form).map(SimpleLicence::fromModel); } - @POST @Path("validate") @RolesAllowed("federation_admin") @@ -110,7 +124,7 @@ public class LicenceEndpoints { @APIResponse(responseCode = "403", description = "Accès refusé"), @APIResponse(responseCode = "500", description = "Erreur interne du serveur") }) - public Uni valideLicences(@Parameter(description = "Id des membre a valider") List ids) { + public Uni valideLicences(@Parameter(description = "Id des membres a valider") List ids) { return licenceService.valideLicences(ids); } diff --git a/src/main/java/fr/titionfire/ffsaf/rest/WebhookEndpoints.java b/src/main/java/fr/titionfire/ffsaf/rest/WebhookEndpoints.java new file mode 100644 index 0000000..431d3d6 --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/rest/WebhookEndpoints.java @@ -0,0 +1,53 @@ +package fr.titionfire.ffsaf.rest; + +import fr.titionfire.ffsaf.domain.service.WebhookService; +import fr.titionfire.ffsaf.rest.client.dto.HelloassoNotification; +import io.smallrye.mutiny.Uni; +import io.vertx.ext.web.RoutingContext; +import jakarta.inject.Inject; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.eclipse.microprofile.openapi.annotations.Operation; +import org.jboss.logging.Logger; + +@Path("api/webhook") +public class WebhookEndpoints { + private static final Logger LOGGER = Logger.getLogger(WebhookEndpoints.class); + + @Inject + WebhookService webhookService; + + @Inject + RoutingContext context; + + @ConfigProperty(name = "helloasso.webhook.ip-source") + String helloassoIp; + + @ConfigProperty(name = "quarkus.http.proxy.proxy-address-forwarding") + boolean proxyForwarding; + + @POST + @Path("ha") + @Operation(hidden = true) + @Consumes(MediaType.APPLICATION_JSON) + public Uni helloAsso(HelloassoNotification notification) { + String ip; + if (proxyForwarding) { + ip = context.request().getHeader("X-Forwarded-For"); + if (ip == null) + ip = context.request().authority().host(); + } else { + ip = context.request().authority().host(); + } + + if (!helloassoIp.equals(ip)) { + LOGGER.infof("helloAsso webhook reject : bas ip (%s)", ip); + return Uni.createFrom().item(Response.status(Response.Status.FORBIDDEN).build()); + } + return webhookService.helloAssoNotification(notification); + } +} diff --git a/src/main/java/fr/titionfire/ffsaf/rest/client/HelloAssoAuthClient.java b/src/main/java/fr/titionfire/ffsaf/rest/client/HelloAssoAuthClient.java new file mode 100644 index 0000000..820ba19 --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/rest/client/HelloAssoAuthClient.java @@ -0,0 +1,32 @@ +package fr.titionfire.ffsaf.rest.client; + +import fr.titionfire.ffsaf.rest.client.dto.TokenResponse; +import io.smallrye.mutiny.Uni; +import jakarta.ws.rs.*; +import jakarta.ws.rs.core.MediaType; +import org.eclipse.microprofile.rest.client.inject.RegisterRestClient; + +@Path("/") +@RegisterRestClient(configKey = "helloasso-auth") +public interface HelloAssoAuthClient { + + @POST + @Path("/token") + @Consumes(MediaType.APPLICATION_FORM_URLENCODED) + @Produces(MediaType.APPLICATION_JSON) + Uni getToken( + @FormParam("grant_type") String grantType, + @FormParam("client_id") String clientId, + @FormParam("client_secret") String clientSecret + ); + + @POST + @Path("/token") + @Consumes(MediaType.APPLICATION_FORM_URLENCODED) + @Produces(MediaType.APPLICATION_JSON) + Uni refreshToken( + @FormParam("grant_type") String grantType, + @FormParam("client_id") String clientId, + @FormParam("refresh_token") String refreshToken + ); +} \ No newline at end of file diff --git a/src/main/java/fr/titionfire/ffsaf/rest/client/HelloAssoHeadersFactory.java b/src/main/java/fr/titionfire/ffsaf/rest/client/HelloAssoHeadersFactory.java new file mode 100644 index 0000000..91a080d --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/rest/client/HelloAssoHeadersFactory.java @@ -0,0 +1,24 @@ +package fr.titionfire.ffsaf.rest.client; + +import fr.titionfire.ffsaf.domain.service.HelloAssoTokenService; +import io.quarkus.rest.client.reactive.ReactiveClientHeadersFactory; +import io.smallrye.mutiny.Uni; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.ws.rs.core.MultivaluedMap; +import org.jboss.resteasy.reactive.common.util.MultivaluedTreeMap; + +@ApplicationScoped +public class HelloAssoHeadersFactory extends ReactiveClientHeadersFactory { + + @Inject + HelloAssoTokenService helloAssoTokenService; + + @Override + public Uni> getHeaders(MultivaluedMap incomingHeaders, + MultivaluedMap clientOutgoingHeaders) { + MultivaluedMap map = new MultivaluedTreeMap<>(); + return helloAssoTokenService.getValidAccessToken() + .invoke(token -> map.putSingle("Authorization", "Bearer " + token)).map(__ -> map); + } +} diff --git a/src/main/java/fr/titionfire/ffsaf/rest/client/HelloAssoService.java b/src/main/java/fr/titionfire/ffsaf/rest/client/HelloAssoService.java new file mode 100644 index 0000000..dc366fb --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/rest/client/HelloAssoService.java @@ -0,0 +1,51 @@ +package fr.titionfire.ffsaf.rest.client; + +import fr.titionfire.ffsaf.rest.client.dto.CheckoutIntentsRequest; +import fr.titionfire.ffsaf.rest.client.dto.CheckoutIntentsResponse; +import io.quarkus.rest.client.reactive.ClientExceptionMapper; +import io.smallrye.mutiny.Uni; +import jakarta.ws.rs.*; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import org.eclipse.microprofile.rest.client.annotation.RegisterClientHeaders; +import org.eclipse.microprofile.rest.client.inject.RegisterRestClient; + +import java.io.ByteArrayInputStream; +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; + +@Path("/") +@RegisterRestClient(configKey = "helloasso-api") +@RegisterClientHeaders(HelloAssoHeadersFactory.class) +public interface HelloAssoService { + + @GET + @Path("/users/me/organizations") + @Produces("text/plain") + Uni test(); + + @POST + @Path("organizations/{organizationSlug}/checkout-intents") + @Produces(MediaType.APPLICATION_JSON) + @Consumes(MediaType.APPLICATION_JSON) + Uni checkout(@PathParam("organizationSlug") String organizationSlug, + CheckoutIntentsRequest data); + + @ClientExceptionMapper + static RuntimeException toException(Response response, Method method) { + if (!method.getDeclaringClass().getName().equals("fr.titionfire.ffsaf.rest.client.HelloAssoService")) + return null; + + if (method.getName().equals("checkout")) { + if (response.getStatus() == 400) { + if (response.getEntity() instanceof ByteArrayInputStream) { + ByteArrayInputStream error = response.readEntity(ByteArrayInputStream.class); + return new RuntimeException(new String(error.readAllBytes(), StandardCharsets.UTF_8)); + } + + return new RuntimeException("The remote service responded with HTTP 400"); + } + } + return null; + } +} \ No newline at end of file diff --git a/src/main/java/fr/titionfire/ffsaf/rest/client/dto/ApiError.java b/src/main/java/fr/titionfire/ffsaf/rest/client/dto/ApiError.java new file mode 100644 index 0000000..eb40742 --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/rest/client/dto/ApiError.java @@ -0,0 +1,14 @@ +package fr.titionfire.ffsaf.rest.client.dto; + +import io.quarkus.runtime.annotations.RegisterForReflection; + +@RegisterForReflection +public class ApiError { + public String error; + public String error_description; + + @Override + public String toString() { + return error + ": " + error_description; + } +} diff --git a/src/main/java/fr/titionfire/ffsaf/rest/client/dto/CheckoutIntentsRequest.java b/src/main/java/fr/titionfire/ffsaf/rest/client/dto/CheckoutIntentsRequest.java new file mode 100644 index 0000000..8fa8c08 --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/rest/client/dto/CheckoutIntentsRequest.java @@ -0,0 +1,30 @@ +package fr.titionfire.ffsaf.rest.client.dto; + +import io.quarkus.runtime.annotations.RegisterForReflection; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@RegisterForReflection +public class CheckoutIntentsRequest { + public int totalAmount; + public int initialAmount; + public String itemName; + public String backUrl; + public String errorUrl; + public String returnUrl; + public boolean containsDonation; + public Payer payer; + public CheckoutMetadata metadata; + + @Data + @AllArgsConstructor + @RegisterForReflection + public static class Payer { + public String firstName; + public String lastName; + public String email; + } +} diff --git a/src/main/java/fr/titionfire/ffsaf/rest/client/dto/CheckoutIntentsResponse.java b/src/main/java/fr/titionfire/ffsaf/rest/client/dto/CheckoutIntentsResponse.java new file mode 100644 index 0000000..433af7c --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/rest/client/dto/CheckoutIntentsResponse.java @@ -0,0 +1,11 @@ +package fr.titionfire.ffsaf.rest.client.dto; + +import io.quarkus.runtime.annotations.RegisterForReflection; +import lombok.Data; + +@Data +@RegisterForReflection +public class CheckoutIntentsResponse { + public int id; + public String redirectUrl; +} diff --git a/src/main/java/fr/titionfire/ffsaf/rest/client/dto/CheckoutMetadata.java b/src/main/java/fr/titionfire/ffsaf/rest/client/dto/CheckoutMetadata.java new file mode 100644 index 0000000..f1eca04 --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/rest/client/dto/CheckoutMetadata.java @@ -0,0 +1,12 @@ +package fr.titionfire.ffsaf.rest.client.dto; + +import io.quarkus.runtime.annotations.RegisterForReflection; +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +@RegisterForReflection +public class CheckoutMetadata { + public long checkoutDBId; +} diff --git a/src/main/java/fr/titionfire/ffsaf/rest/client/dto/HelloassoNotification.java b/src/main/java/fr/titionfire/ffsaf/rest/client/dto/HelloassoNotification.java new file mode 100644 index 0000000..550236d --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/rest/client/dto/HelloassoNotification.java @@ -0,0 +1,16 @@ +package fr.titionfire.ffsaf.rest.client.dto; + +import io.quarkus.runtime.annotations.RegisterForReflection; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@RegisterForReflection +public class HelloassoNotification { + private NotificationData data; + private String eventType; + private CheckoutMetadata metadata; +} diff --git a/src/main/java/fr/titionfire/ffsaf/rest/client/dto/NotificationData.java b/src/main/java/fr/titionfire/ffsaf/rest/client/dto/NotificationData.java new file mode 100644 index 0000000..8d79e6e --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/rest/client/dto/NotificationData.java @@ -0,0 +1,30 @@ +package fr.titionfire.ffsaf.rest.client.dto; + +import io.quarkus.runtime.annotations.RegisterForReflection; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@RegisterForReflection +public class NotificationData { + private Order order; + private Integer id; + private String organizationSlug; + private String checkoutIntentId; + private String oldSlugOrganization; // Pour les changements de nom d'association + private String newSlugOrganization; + private String state; // Pour les formulaires + + + @Data + @NoArgsConstructor + @AllArgsConstructor + @RegisterForReflection + public static class Order { + private Integer id; + private String organizationSlug; + } +} diff --git a/src/main/java/fr/titionfire/ffsaf/rest/client/dto/TokenResponse.java b/src/main/java/fr/titionfire/ffsaf/rest/client/dto/TokenResponse.java new file mode 100644 index 0000000..3639533 --- /dev/null +++ b/src/main/java/fr/titionfire/ffsaf/rest/client/dto/TokenResponse.java @@ -0,0 +1,30 @@ +package fr.titionfire.ffsaf.rest.client.dto; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.quarkus.runtime.annotations.RegisterForReflection; + +@RegisterForReflection +public class TokenResponse { + @JsonProperty("access_token") + public String accessToken; + + @JsonProperty("refresh_token") + public String refreshToken; + + @JsonProperty("token_type") + public String tokenType; // Toujours "bearer" + + @JsonProperty("expires_in") + public long expiresIn; // Durée de validité en secondes (1800s = 30min) + + // Pour stocker l'heure d'obtention du token + private long timestamp; + + public TokenResponse() { + this.timestamp = System.currentTimeMillis() / 1000; // Timestamp en secondes + } + + // Vérifie si le token est expiré + public boolean isExpired() { + return (System.currentTimeMillis() / 1000) - timestamp >= expiresIn; + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 231f1b1..0e1ae68 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -63,4 +63,14 @@ quarkus.http.auth.permission.public.policy=permit quarkus.keycloak.admin-client.server-url=https://auth.safca.fr -quarkus.native.resources.includes=asset/** \ No newline at end of file +quarkus.native.resources.includes=asset/** + +# HelloAsso Connector +helloasso.api=https://api.helloasso.com +helloasso.client-id=changeme +helloasso.client-secret=changeme + +quarkus.rest-client.helloasso-auth.url=${helloasso.api}/oauth2 +quarkus.rest-client.helloasso-auth.scope=javax.inject.Singleton + +quarkus.rest-client.helloasso-api.url=${helloasso.api}/v5 diff --git a/src/main/webapp/src/pages/MemberList.jsx b/src/main/webapp/src/pages/MemberList.jsx index 07a7adb..2020d40 100644 --- a/src/main/webapp/src/pages/MemberList.jsx +++ b/src/main/webapp/src/pages/MemberList.jsx @@ -98,6 +98,9 @@ export function MemberList({source}) { {source === "admin" && } + {source === "club" && + }
Filtre
diff --git a/src/main/webapp/src/pages/PayAndValidateList.css b/src/main/webapp/src/pages/PayAndValidateList.css new file mode 100644 index 0000000..8215728 --- /dev/null +++ b/src/main/webapp/src/pages/PayAndValidateList.css @@ -0,0 +1,112 @@ +.HaPay { + width: fit-content; + display: -webkit-box; + display: -ms-flexbox; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + -webkit-box-pack: center; + -ms-flex-pack: center; +} + +.HaPay * { + font-family: "Open Sans", "Trebuchet MS", "Lucida Sans Unicode", + "Lucida Grande", "Lucida Sans", Arial, sans-serif; + transition: all 0.3s ease-out; +} + +.HaPayButton { + align-items: stretch; + -webkit-box-pack: stretch; + -ms-flex-pack: stretch; + background: none; + border: none; + display: -webkit-box; + display: -ms-flexbox; + display: flex; + padding: 0; + border-radius: 8px; +} + +.HaPayButton:hover { + cursor: pointer; +} + +.HaPayButton:not(:disabled):focus { + box-shadow: 0 0 0 0.25rem rgba(73, 211, 138, 0.25); + -webkit-box-shadow: 0 0 0 0.25rem rgba(73, 211, 138, 0.25); +} + +.HaPayButton:not(:disabled):hover .HaPayButtonLabel, +.HaPayButton:not(:disabled):focus .HaPayButtonLabel { + background-color: #483dbe; +} + +.HaPayButton:not(:disabled):hover .HaPayButtonLogo, +.HaPayButton:not(:disabled):focus .HaPayButtonLogo, +.HaPayButton:not(:disabled):hover .HaPayButtonLabel, +.HaPayButton:not(:disabled):focus .HaPayButtonLabel { + border: 1px solid #483dbe; +} + +.HaPayButton:disabled { + cursor: not-allowed; +} + +.HaPayButton:disabled .HaPayButtonLogo, +.HaPayButton:disabled .HaPayButtonLabel { + border: 1px solid #d1d6de; +} + +.HaPayButtonLogo { + background-color: #ffffff; + border: 1px solid #4c40cf; + border-top-left-radius: 8px; + border-bottom-left-radius: 8px; + padding: 7px 10px; + width: 50px; +} + +.HaPayButtonLabel { + align-items: center; + -webkit-box-pack: center; + -ms-flex-pack: center; + justify-content: space-between; + column-gap: 5px; + background-color: #4c40cf; + border: 1px solid #4c40cf; + border-top-right-radius: 8px; + border-bottom-right-radius: 8px; + color: #ffffff; + font-size: 16px; + font-weight: 800; + display: -webkit-box; + display: -ms-flexbox; + display: flex; + padding: 0 16px; +} + +.HaPayButton:disabled .HaPayButtonLabel { + background-color: #d1d6de; + color: #505870; +} + +.HaPaySecured { + align-items: center; + -webkit-box-pack: center; + -ms-flex-pack: center; + justify-content: space-between; + display: -webkit-box; + display: -ms-flexbox; + display: flex; + column-gap: 5px; + padding: 8px 16px; + font-size: 12px; + font-weight: 600; + color: #2e2f5e; +} + +.HaPay svg { + fill: currentColor; +} diff --git a/src/main/webapp/src/pages/ValidateList.jsx b/src/main/webapp/src/pages/PayAndValidateList.jsx similarity index 54% rename from src/main/webapp/src/pages/ValidateList.jsx rename to src/main/webapp/src/pages/PayAndValidateList.jsx index 2969d06..6e0cc66 100644 --- a/src/main/webapp/src/pages/ValidateList.jsx +++ b/src/main/webapp/src/pages/PayAndValidateList.jsx @@ -9,9 +9,11 @@ import {toast} from "react-toastify"; import {SearchBar} from "../components/SearchBar.jsx"; import {ConfirmDialog} from "../components/ConfirmDialog.jsx"; import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; -import {faEuroSign} from "@fortawesome/free-solid-svg-icons"; +import {faCircleInfo, faEuroSign} from "@fortawesome/free-solid-svg-icons"; +import "./PayAndValidateList.css"; + +export function PayAndValidateList({source}) { -export function ValidateList({source}) { const {hash} = useLocation(); const navigate = useNavigate(); let page = Number(hash.substring(1)); @@ -20,11 +22,12 @@ export function ValidateList({source}) { const [memberData, setMemberData] = useState([]); const [licenceData, setLicenceData] = useState([]); const [clubFilter, setClubFilter] = useState(""); - const [stateFilter, setStateFilter] = useState(2) + const [stateFilter, setStateFilter] = useState((source === "club") ? 1 : 2) const [lastSearch, setLastSearch] = useState(""); - const [paymentFilter, setPaymentFilter] = useState(2); + const [paymentFilter, setPaymentFilter] = useState((source === "club") ? 0 : 2); - const [selectedMembers, setSelectedMembers] = useState([]); + const storedMembers = sessionStorage.getItem("selectedMembers"); + const [selectedMembers, setSelectedMembers] = useState(storedMembers ? JSON.parse(storedMembers) : []); const setLoading = useLoadingSwitcher() const { @@ -33,6 +36,9 @@ export function ValidateList({source}) { refresh } = useFetch(`/member/find/${source}?page=${page}&licenceRequest=${stateFilter}&payment=${paymentFilter}`, setLoading, 1) + useEffect(() => { + sessionStorage.setItem("selectedMembers", JSON.stringify(selectedMembers)); + }, [selectedMembers]); useEffect(() => { refresh(`/member/find/${source}?page=${page}&search=${lastSearch}&club=${clubFilter}&licenceRequest=${stateFilter}&payment=${paymentFilter}`); @@ -102,9 +108,31 @@ export function ValidateList({source}) { }); } + const handlePay = () => { + if (selectedMembers.length === 0) { + toast.error("Aucun membre sélectionné"); + return; + } + + toast.promise( + apiAxios.post(`/licence/pay`, selectedMembers), + { + pending: "Création de la commande en cours...", + success: "Commande crée avec succès 🎉", + error: { + render({data}) { + return errFormater(data, "Échec de le création de la commande") + } + } + } + ).then((data) => { + window.location.href = data.data; + }); + } + return <>

Validation des licences

-
@@ -120,15 +148,14 @@ export function ValidateList({source}) { }
- {source !== "club" && -
-
Filtre
-
- -
-
} +
+
Filtre
+
+ +
+
{source === "admin" && <> @@ -139,6 +166,22 @@ export function ValidateList({source}) { message={"Êtes-vous sûr de vouloir valider les " + selectedMembers.length + " licences ?"} onConfirm={handleValidation} id="confirm-validation"/> } + + {source === "club" && <> + {selectedMembers.length} licences sélectionnée
Total à régler : {selectedMembers.length * 15}€
+ +
+
+ + A propos de HelloAsso +
+
+ Le modèle solidaire de HelloAsso garantit que 100% de votre paiement sera versé à l’association choisie. Vous + pouvez soutenir l’aide qu’ils apportent aux associations en laissant une contribution volontaire à HelloAsso au + moment de votre paiement. +
+
+ }
@@ -147,6 +190,88 @@ export function ValidateList({source}) { } +function HaPay({onClick}) { + return <> +
+ +
+ + + + Paiement sécurisé + Logo Visa + Logo Mastercard + Logo CB + Logo PCI +
+
+ +} + function MakeCentralPanel({data, visibleMember, navigate, page, source, selectedMembers, setSelectedMembers}) { const lastCheckedRef = useRef(null); @@ -232,7 +357,7 @@ function MakeRow({member, source, isChecked, onCheckboxClick, onRowClick}) { }} onClick={(e) => onCheckboxClick(e, member.id)}/> {(member.licence_number ? String(member.licence_number).padStart(5, '0') : "-------") + " "} - {(member.licence != null && member.licence.pay)? : <>  } + {(member.licence != null && member.licence.pay) ? : <>  }
{member.fname} {member.lname}
@@ -272,18 +397,19 @@ function FiltreBar({data, clubFilter, setClubFilter, source, stateFilter, setSta {source !== "club" && }
-
+ {source !== "club" &&
-
+
}
} diff --git a/src/main/webapp/src/pages/admin/AdminRoot.jsx b/src/main/webapp/src/pages/admin/AdminRoot.jsx index 74ab595..ee9bc53 100644 --- a/src/main/webapp/src/pages/admin/AdminRoot.jsx +++ b/src/main/webapp/src/pages/admin/AdminRoot.jsx @@ -10,7 +10,7 @@ import {NewClubPage} from "./club/NewClubPage.jsx"; import {ClubPage} from "./club/ClubPage.jsx"; import {AffiliationReqList} from "./affiliation/AffiliationReqList.jsx"; import {StatsPage} from "./StatsPage.jsx"; -import {ValidateList} from "../ValidateList.jsx"; +import {PayAndValidateList} from "../PayAndValidateList.jsx"; export function AdminRoot() { return <> @@ -37,7 +37,7 @@ export function getAdminChildren() { }, { path: 'member/validate', - element: + element: }, { path: 'club', diff --git a/src/main/webapp/src/pages/club/ClubRoot.jsx b/src/main/webapp/src/pages/club/ClubRoot.jsx index d7a7216..70cccf8 100644 --- a/src/main/webapp/src/pages/club/ClubRoot.jsx +++ b/src/main/webapp/src/pages/club/ClubRoot.jsx @@ -5,6 +5,9 @@ import {useAuth} from "../../hooks/useAuth.jsx"; import {MemberList} from "../MemberList.jsx"; import {NewMemberPage} from "./member/NewMemberPage.jsx"; import {MyClubPage} from "./club/MyClubPage.jsx"; +import {PayAndValidateList} from "../PayAndValidateList.jsx"; +import {PaymentError} from "./PaymentError.jsx"; +import {PaymentReturn} from "./PaymentReturn.jsx"; export function ClubRoot() { const {userinfo} = useAuth() @@ -33,6 +36,18 @@ export function getClubChildren() { path: 'member', element: }, + { + path: 'member/pay', + element: + }, + { + path: 'member/pay/error', + element: + }, + { + path: 'member/pay/return', + element: + }, { path: 'member/:id', element: @@ -46,4 +61,4 @@ export function getClubChildren() { element: } ] -} \ No newline at end of file +} diff --git a/src/main/webapp/src/pages/club/PaymentError.jsx b/src/main/webapp/src/pages/club/PaymentError.jsx new file mode 100644 index 0000000..f7330cb --- /dev/null +++ b/src/main/webapp/src/pages/club/PaymentError.jsx @@ -0,0 +1,19 @@ +import {useSearchParams} from "react-router-dom"; + +export function PaymentError () { + const [searchParams, setSearchParams] = useSearchParams(); + + const error = searchParams.get("error"); + + return
+
+
+

Erreur de paiement😕

+

Une erreur est survenue lors du traitement de votre paiement. Veuillez réessayer plus tard.

+

Message d'erreur : {error}

+ + +
+
+
; +} diff --git a/src/main/webapp/src/pages/club/PaymentReturn.jsx b/src/main/webapp/src/pages/club/PaymentReturn.jsx new file mode 100644 index 0000000..31627f1 --- /dev/null +++ b/src/main/webapp/src/pages/club/PaymentReturn.jsx @@ -0,0 +1,16 @@ +import {useNavigate} from "react-router-dom"; + +export function PaymentReturn() { + const navigate = useNavigate(); + + return
+
+
+

🎉Votre paiement a été traité avec succès.🎉

+

Merci pour votre paiement. Les licences devraient être activées dans l'heure qui vient, à condition que le certificat médical soit rempli.

+ + +
+
+
; +} diff --git a/src/main/webapp/src/utils/Tools.js b/src/main/webapp/src/utils/Tools.js index a9f105e..2d7d046 100644 --- a/src/main/webapp/src/utils/Tools.js +++ b/src/main/webapp/src/utils/Tools.js @@ -9,7 +9,9 @@ apiAxios.defaults.headers.post['Accept'] = 'application/json; charset=UTF-8'; export const errFormater = (data, msg) => { - return `${msg} (${data.response.statusText}: ${data.response.data}) 😕` + if (typeof data.response.data === 'string' || data.response.data instanceof String) + return `${msg} (${data.response.statusText}: ${data.response.data}) 😕` + return `${msg} (${data.response.statusText}: ${JSON.stringify(data.response.data)}) 😕` } export function getCategoryFormBirthDate(birth_date, currentDate = new Date()) { @@ -48,4 +50,4 @@ export function getSaison(currentDate = new Date()) { } else { return currentDate.getFullYear() - 1 } -} \ No newline at end of file +}