feat: HelloAsso payment

This commit is contained in:
2025-08-14 22:45:03 +02:00
parent 0a56f8c180
commit 15f65b1014
28 changed files with 1003 additions and 31 deletions

View File

@@ -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);
}

View 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);
}
}

View File

@@ -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
);
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}