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

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

View File

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

View File

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

View File

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

View File

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