feat: HelloAsso payment
This commit is contained in:
parent
0a56f8c180
commit
a8ecc5b573
@ -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<Long> licenseIds;
|
||||
|
||||
Integer checkoutId;
|
||||
|
||||
PaymentStatus paymentStatus;
|
||||
|
||||
public enum PaymentStatus {
|
||||
PENDING, AUTHORIZED, REFUSED, UNKNOW, REGISTERED, REFUNDING, REFUNDED, CONTESTED
|
||||
}
|
||||
}
|
||||
@ -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<CheckoutModel, Long> {
|
||||
}
|
||||
@ -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<Boolean> canDeleteLicence(long id) {
|
||||
return repository.find("?1 IN licenseIds", id).count().map(count -> count == 0);
|
||||
}
|
||||
|
||||
public Uni<String> create(List<Long> 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<Response> 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());
|
||||
}
|
||||
}
|
||||
@ -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<String> 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<String> 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<String> 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);
|
||||
}
|
||||
}
|
||||
@ -42,6 +42,9 @@ public class LicenceService {
|
||||
@Inject
|
||||
LoggerService ls;
|
||||
|
||||
@Inject
|
||||
CheckoutService checkoutService;
|
||||
|
||||
public Uni<List<LicenceModel>> getLicence(long id, Consumer<MembreModel> 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<LicenceModel> validateLicences(LicenceModel model) {
|
||||
model.setValidate(true);
|
||||
return Panache.withTransaction(() -> repository.persist(model)
|
||||
.call(m -> Mutiny.fetch(m.getMembre())
|
||||
.call(genLicenceNumberAndAccountIfNeed())
|
||||
));
|
||||
}
|
||||
|
||||
public Uni<LicenceModel> 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<String> payLicences(List<Long> ids, Consumer<MembreModel> 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<MembreModel> 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");
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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<Response> 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());
|
||||
}
|
||||
}
|
||||
@ -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<String> payLicences(@Parameter(description = "Id des membres") List<Long> 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<Long> ids) {
|
||||
public Uni<?> valideLicences(@Parameter(description = "Id des membres a valider") List<Long> ids) {
|
||||
return licenceService.valideLicences(ids);
|
||||
}
|
||||
|
||||
|
||||
53
src/main/java/fr/titionfire/ffsaf/rest/WebhookEndpoints.java
Normal file
53
src/main/java/fr/titionfire/ffsaf/rest/WebhookEndpoints.java
Normal file
@ -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<Response> 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);
|
||||
}
|
||||
}
|
||||
@ -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<TokenResponse> 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<TokenResponse> refreshToken(
|
||||
@FormParam("grant_type") String grantType,
|
||||
@FormParam("client_id") String clientId,
|
||||
@FormParam("refresh_token") String refreshToken
|
||||
);
|
||||
}
|
||||
@ -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<MultivaluedMap<String, String>> getHeaders(MultivaluedMap<String, String> incomingHeaders,
|
||||
MultivaluedMap<String, String> clientOutgoingHeaders) {
|
||||
MultivaluedMap<String, String> map = new MultivaluedTreeMap<>();
|
||||
return helloAssoTokenService.getValidAccessToken()
|
||||
.invoke(token -> map.putSingle("Authorization", "Bearer " + token)).map(__ -> map);
|
||||
}
|
||||
}
|
||||
@ -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<String> test();
|
||||
|
||||
@POST
|
||||
@Path("organizations/{organizationSlug}/checkout-intents")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
Uni<CheckoutIntentsResponse> 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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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/**
|
||||
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
|
||||
|
||||
@ -98,6 +98,9 @@ export function MemberList({source}) {
|
||||
{source === "admin" &&
|
||||
<button className="btn btn-primary" onClick={() => navigate("validate")} style={{marginTop: "0.5rem"}}>Valider des
|
||||
licences</button>}
|
||||
{source === "club" &&
|
||||
<button className="btn btn-primary" onClick={() => navigate("pay")} style={{marginTop: "0.5rem"}}>Paiement des
|
||||
licences</button>}
|
||||
</div>
|
||||
<div className="card mb-4">
|
||||
<div className="card-header">Filtre</div>
|
||||
|
||||
112
src/main/webapp/src/pages/PayAndValidateList.css
Normal file
112
src/main/webapp/src/pages/PayAndValidateList.css
Normal file
@ -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;
|
||||
}
|
||||
@ -9,9 +9,10 @@ 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 ValidateList({source}) {
|
||||
export function PayAndValidateList({source}) {
|
||||
const {hash} = useLocation();
|
||||
const navigate = useNavigate();
|
||||
let page = Number(hash.substring(1));
|
||||
@ -20,11 +21,11 @@ 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 [selectedMembers, setSelectedMembers] = useState([]); // TODO: store in local storage
|
||||
|
||||
const setLoading = useLoadingSwitcher()
|
||||
const {
|
||||
@ -102,9 +103,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 <>
|
||||
<h2>Validation des licences</h2>
|
||||
<button type="button" className="btn btn-link" onClick={() => navigate("/admin/member")}>
|
||||
<button type="button" className="btn btn-link" onClick={() => navigate("../member")}>
|
||||
« retour
|
||||
</button>
|
||||
<div>
|
||||
@ -120,15 +143,14 @@ export function ValidateList({source}) {
|
||||
}
|
||||
</div>
|
||||
<div className="col-lg-3">
|
||||
{source !== "club" &&
|
||||
<div className="card mb-4">
|
||||
<div className="card-header">Filtre</div>
|
||||
<div className="card-body">
|
||||
<FiltreBar data={data} clubFilter={clubFilter} setClubFilter={setClubFilter} source={source}
|
||||
stateFilter={stateFilter} setStateFilter={setStateFilter} paymentFilter={paymentFilter}
|
||||
setPaymentFilter={setPaymentFilter}/>
|
||||
</div>
|
||||
</div>}
|
||||
<div className="card mb-4">
|
||||
<div className="card-header">Filtre</div>
|
||||
<div className="card-body">
|
||||
<FiltreBar data={data} clubFilter={clubFilter} setClubFilter={setClubFilter} source={source}
|
||||
stateFilter={stateFilter} setStateFilter={setStateFilter} paymentFilter={paymentFilter}
|
||||
setPaymentFilter={setPaymentFilter}/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
{source === "admin" && <>
|
||||
@ -139,6 +161,22 @@ export function ValidateList({source}) {
|
||||
message={"Êtes-vous sûr de vouloir valider les " + selectedMembers.length + " licences ?"}
|
||||
onConfirm={handleValidation} id="confirm-validation"/>
|
||||
</>}
|
||||
|
||||
{source === "club" && <>
|
||||
<span>{selectedMembers.length} licences sélectionnée<br/>Total à régler : {selectedMembers.length * 15}€</span>
|
||||
<HaPay onClick={handlePay}/>
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<FontAwesomeIcon icon={faCircleInfo} size="2xl" style={{color: "#74C0FC", marginRight: "0.25em"}}/>
|
||||
A propos de HelloAsso
|
||||
</div>
|
||||
<div className="card-body">
|
||||
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.
|
||||
</div>
|
||||
</div>
|
||||
</>}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@ -147,6 +185,88 @@ export function ValidateList({source}) {
|
||||
</>
|
||||
}
|
||||
|
||||
function HaPay({onClick}) {
|
||||
return <>
|
||||
<div className="HaPay" style={{marginTop: "0.5em"}}>
|
||||
<button className="HaPayButton" onClick={onClick}>
|
||||
<img
|
||||
src="https://api.helloasso.com/v5/img/logo-ha.svg"
|
||||
alt=""
|
||||
className="HaPayButtonLogo"
|
||||
/>
|
||||
<div className="HaPayButtonLabel">
|
||||
<span> Payer avec </span>
|
||||
<svg
|
||||
width="73"
|
||||
height="14"
|
||||
viewBox="0 0 73 14"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M72.9992 8.78692C72.9992 11.7371 71.242 13.6283 68.4005 13.6283C65.5964 13.6283 63.8018 11.9073 63.8018 8.74909C63.8018 5.79888 65.559 3.90771 68.4005 3.90771C71.2046 3.90771 72.9992 5.64759 72.9992 8.78692ZM67.2041 8.74909C67.2041 10.5457 67.5779 11.2265 68.4005 11.2265C69.223 11.2265 69.5969 10.5079 69.5969 8.78692C69.5969 6.99031 69.223 6.30949 68.4005 6.30949C67.5779 6.30949 67.1854 7.04705 67.2041 8.74909Z"
|
||||
/>
|
||||
<path
|
||||
d="M62.978 5.08045L61.8003 6.89597C61.1647 6.47991 60.4356 6.25297 59.6692 6.23406C59.1084 6.23406 58.9214 6.40426 58.9214 6.65011C58.9214 6.9527 59.0149 7.08508 60.716 7.61461C62.4172 8.14413 63.3332 8.88169 63.3332 10.527C63.3332 12.3803 61.576 13.6474 59.1084 13.6474C57.5381 13.6474 56.0986 13.0801 55.1826 12.2101L56.7529 10.4514C57.3885 10.962 58.211 11.3402 59.0336 11.3402C59.6131 11.3402 59.9683 11.1511 59.9683 10.7918C59.9683 10.3568 59.7813 10.2622 58.2484 9.78945C56.5847 9.27883 55.65 8.31434 55.65 6.85814C55.65 5.23174 57.0333 3.92684 59.5383 3.92684C60.8656 3.90793 62.1555 4.36181 62.978 5.08045Z"
|
||||
/>
|
||||
<path
|
||||
d="M54.7358 5.08045L53.5581 6.89597C52.9225 6.47991 52.1934 6.25297 51.427 6.23406C50.8662 6.23406 50.6792 6.40426 50.6792 6.65011C50.6792 6.9527 50.7727 7.08508 52.4738 7.61461C54.175 8.14413 55.091 8.88169 55.091 10.527C55.091 12.3803 53.3338 13.6474 50.8662 13.6474C49.2959 13.6474 47.8564 13.0801 46.9404 12.2101L48.5107 10.4514C49.1463 10.962 49.9689 11.3402 50.7914 11.3402C51.3709 11.3402 51.7261 11.1511 51.7261 10.7918C51.7261 10.3568 51.5391 10.2622 50.0062 9.78945C48.3238 9.27883 47.4078 8.31434 47.4078 6.85814C47.4078 5.23174 48.7911 3.92684 51.2961 3.92684C52.6234 3.90793 53.9133 4.36181 54.7358 5.08045Z"
|
||||
/>
|
||||
<path
|
||||
d="M46.7721 11.4156L46.0991 13.5526C44.9401 13.477 44.1923 13.1555 43.6876 12.3045C43.0333 13.3068 42.0051 13.6283 40.9956 13.6283C39.201 13.6283 38.042 12.418 38.042 10.7537C38.042 8.74909 39.5375 7.65222 42.3603 7.65222H42.9959V7.42528C42.9959 6.51752 42.6968 6.27167 41.706 6.27167C40.9209 6.30949 40.1357 6.4797 39.4067 6.74446L38.6963 4.62636C39.8179 4.17248 41.0143 3.94554 42.2294 3.90771C45.0709 3.90771 46.23 5.00459 46.23 7.23616V10.3566C46.23 10.9996 46.3795 11.2643 46.7721 11.4156ZM43.0146 10.7348V9.39209H42.6594C41.7247 9.39209 41.2947 9.71359 41.2947 10.4133C41.2947 10.9239 41.5752 11.2643 42.0238 11.2643C42.4164 11.2643 42.7903 11.0563 43.0146 10.7348Z"
|
||||
/>
|
||||
<path
|
||||
d="M37.5363 8.78692C37.5363 11.7371 35.7791 13.6283 32.9376 13.6283C30.1335 13.6283 28.3389 11.9073 28.3389 8.74909C28.3389 5.79888 30.0961 3.90771 32.9376 3.90771C35.7417 3.90771 37.5363 5.64759 37.5363 8.78692ZM31.7412 8.74909C31.7412 10.5457 32.1151 11.2265 32.9376 11.2265C33.7601 11.2265 34.134 10.5079 34.134 8.78692C34.134 6.99031 33.7601 6.30949 32.9376 6.30949C32.1151 6.30949 31.7225 7.04705 31.7412 8.74909Z"
|
||||
/>
|
||||
<path
|
||||
d="M23.8154 10.6972V0.692948L27.1243 0.352539V10.527C27.1243 10.8296 27.2551 10.9809 27.5355 10.9809C27.6477 10.9809 27.7786 10.962 27.8907 10.9052L28.4889 13.2881C27.8907 13.4961 27.2738 13.6096 26.6569 13.5907C24.8249 13.6285 23.8154 12.5505 23.8154 10.6972Z"
|
||||
/>
|
||||
<path
|
||||
d="M18.8057 10.6972V0.692948L22.1145 0.352539V10.527C22.1145 10.8296 22.2454 10.9809 22.5071 10.9809C22.6192 10.9809 22.7501 10.962 22.8623 10.9052L23.4418 13.2881C22.8436 13.4961 22.2267 13.6096 21.6098 13.5907C19.8151 13.6285 18.8057 12.5505 18.8057 10.6972Z"
|
||||
/>
|
||||
<path
|
||||
d="M17.9071 9.71359H12.4859C12.6728 11.0185 13.3084 11.2454 14.2805 11.2454C14.9161 11.2454 15.533 10.9807 16.2994 10.4511L17.6454 12.2856C16.6172 13.1555 15.3087 13.6283 13.9627 13.6283C10.6912 13.6283 9.13965 11.5858 9.13965 8.78692C9.13965 6.13929 10.6352 3.90771 13.5888 3.90771C16.2247 3.90771 17.9632 5.60976 17.9632 8.63562C17.9819 8.93821 17.9445 9.39209 17.9071 9.71359ZM14.7291 7.70895C14.7105 6.80119 14.5235 6.04473 13.6823 6.04473C12.9719 6.04473 12.6167 6.46079 12.4859 7.84134H14.7291V7.70895Z"
|
||||
/>
|
||||
<path
|
||||
d="M8.24307 6.61229V13.2692H4.93423V7.21746C4.93423 6.49882 4.7286 6.32862 4.4295 6.32862C4.07431 6.32862 3.70043 6.61229 3.30786 7.21746V13.2503H-0.000976562V0.692948L3.30786 0.352539V5.06154C4.07431 4.24834 4.82207 3.90793 5.83154 3.90793C7.32706 3.90793 8.24307 4.89133 8.24307 6.61229Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
<div className="HaPaySecured">
|
||||
<svg
|
||||
width="9"
|
||||
height="10"
|
||||
viewBox="0 0 11 12"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M3.875 3V4.5H7.625V3C7.625 1.96875 6.78125 1.125 5.75 1.125C4.69531 1.125 3.875 1.96875 3.875 3ZM2.75 4.5V3C2.75 1.35938 4.08594 0 5.75 0C7.39062 0 8.75 1.35938 8.75 3V4.5H9.5C10.3203 4.5 11 5.17969 11 6V10.5C11 11.3438 10.3203 12 9.5 12H2C1.15625 12 0.5 11.3438 0.5 10.5V6C0.5 5.17969 1.15625 4.5 2 4.5H2.75ZM1.625 6V10.5C1.625 10.7109 1.78906 10.875 2 10.875H9.5C9.6875 10.875 9.875 10.7109 9.875 10.5V6C9.875 5.8125 9.6875 5.625 9.5 5.625H2C1.78906 5.625 1.625 5.8125 1.625 6Z"
|
||||
/>
|
||||
</svg>
|
||||
<span>Paiement sécurisé</span>
|
||||
<img
|
||||
src="https://helloassodocumentsprod.blob.core.windows.net/public-documents/bouton_payer_avec_helloasso/logo-visa.svg"
|
||||
alt="Logo Visa"
|
||||
/>
|
||||
<img
|
||||
src="https://helloassodocumentsprod.blob.core.windows.net/public-documents/bouton_payer_avec_helloasso/logo-mastercard.svg"
|
||||
alt="Logo Mastercard"
|
||||
/>
|
||||
<img
|
||||
src="https://helloassodocumentsprod.blob.core.windows.net/public-documents/bouton_payer_avec_helloasso/logo-cb.svg"
|
||||
alt="Logo CB"
|
||||
/>
|
||||
<img
|
||||
src="https://helloassodocumentsprod.blob.core.windows.net/public-documents/bouton_payer_avec_helloasso/logo-pci.svg"
|
||||
alt="Logo PCI"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
|
||||
function MakeCentralPanel({data, visibleMember, navigate, page, source, selectedMembers, setSelectedMembers}) {
|
||||
const lastCheckedRef = useRef(null);
|
||||
|
||||
@ -232,7 +352,7 @@ function MakeRow({member, source, isChecked, onCheckboxClick, onRowClick}) {
|
||||
}}
|
||||
onClick={(e) => onCheckboxClick(e, member.id)}/>
|
||||
<span className="col-auto">{(member.licence_number ? String(member.licence_number).padStart(5, '0') : "-------") + " "}
|
||||
{(member.licence != null && member.licence.pay)? <FontAwesomeIcon icon={faEuroSign}/> : <> </>}</span>
|
||||
{(member.licence != null && member.licence.pay) ? <FontAwesomeIcon icon={faEuroSign}/> : <> </>}</span>
|
||||
</div>
|
||||
<div className="ms-2 col-auto">
|
||||
<div className="fw-bold">{member.fname} {member.lname}</div>
|
||||
@ -272,18 +392,19 @@ function FiltreBar({data, clubFilter, setClubFilter, source, stateFilter, setSta
|
||||
{source !== "club" && <ClubSelectFilter clubFilter={clubFilter} setClubFilter={setClubFilter}/>}
|
||||
<div className="mb-3">
|
||||
<select className="form-select" value={stateFilter} onChange={event => setStateFilter(Number(event.target.value))}>
|
||||
<option value={1}>Avec demande ou licence validée</option>
|
||||
<option value={2}>Demande en cours</option>
|
||||
<option value={5}>Demande complet</option>
|
||||
<option value={6}>Demande incomplet</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
{source !== "club" && <div className="mb-3">
|
||||
<select className="form-select" value={paymentFilter} onChange={event => setPaymentFilter(Number(event.target.value))}>
|
||||
<option value={2}>Tout les états de paiement</option>
|
||||
<option value={0}>Sans paiement</option>
|
||||
<option value={1}>Avec paiement</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>}
|
||||
</div>
|
||||
}
|
||||
|
||||
@ -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: <ValidateList source="admin"/>
|
||||
element: <PayAndValidateList source="admin"/>
|
||||
},
|
||||
{
|
||||
path: 'club',
|
||||
|
||||
@ -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: <MemberList source="club"/>
|
||||
},
|
||||
{
|
||||
path: 'member/pay',
|
||||
element: <PayAndValidateList source="club"/>
|
||||
},
|
||||
{
|
||||
path: 'member/pay/error',
|
||||
element: <PaymentError/>
|
||||
},
|
||||
{
|
||||
path: 'member/pay/return',
|
||||
element: <PaymentReturn/>
|
||||
},
|
||||
{
|
||||
path: 'member/:id',
|
||||
element: <MemberPage/>
|
||||
@ -46,4 +61,4 @@ export function getClubChildren() {
|
||||
element: <MyClubPage/>
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
19
src/main/webapp/src/pages/club/PaymentError.jsx
Normal file
19
src/main/webapp/src/pages/club/PaymentError.jsx
Normal file
@ -0,0 +1,19 @@
|
||||
import {useSearchParams} from "react-router-dom";
|
||||
|
||||
export function PaymentError () {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const error = searchParams.get("error");
|
||||
|
||||
return <div className="container">
|
||||
<div className="row">
|
||||
<div className="col-md-12" style={{textAlign: "center"}}>
|
||||
<h1>Erreur de paiement😕</h1>
|
||||
<p>Une erreur est survenue lors du traitement de votre paiement. Veuillez réessayer plus tard.</p>
|
||||
<p><strong>Message d'erreur :</strong> {error}</p>
|
||||
|
||||
<button className="btn btn-primary" onClick={() => navigate("/club/member")} style={{marginTop: "0.5rem"}}>Retour à la liste de membres</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
16
src/main/webapp/src/pages/club/PaymentReturn.jsx
Normal file
16
src/main/webapp/src/pages/club/PaymentReturn.jsx
Normal file
@ -0,0 +1,16 @@
|
||||
import {useNavigate} from "react-router-dom";
|
||||
|
||||
export function PaymentReturn() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return <div className="container">
|
||||
<div className="row">
|
||||
<div className="col-md-12" style={{textAlign: "center"}}>
|
||||
<h1>🎉Votre paiement a été traité avec succès.🎉</h1>
|
||||
<p>Merci pour votre paiement. Les licences devraient être activées dans l'heure qui vient, à condition que le certificat médical soit rempli.</p>
|
||||
|
||||
<button className="btn btn-primary" onClick={() => navigate("/club/member")} style={{marginTop: "0.5rem"}}>Retour à la liste de membres</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user