fix(security): audit RBAC complet v3.0 — rôles normalisés, lifecycle, changement mdp mobile

RBAC:
- HealthResource: @PermitAll
- RoleResource: @RolesAllowed ADMIN/SUPER_ADMIN/ADMIN_ORGANISATION class-level
- PropositionAideResource: @RolesAllowed MEMBRE/USER class-level
- AuthCallbackResource: @PermitAll
- EvenementResource: @PermitAll /publics et /test, count restreint
- BackupResource/LogsMonitoringResource/SystemResource: MODERATOR → MODERATEUR
- AnalyticsResource: MANAGER/MEMBER → ADMIN_ORGANISATION/MEMBRE
- RoleConstant.java: constantes de rôles centralisées

Cycle de vie membres:
- MemberLifecycleService: ajouterMembre()/retirerMembre() sur activation/radiation/archivage
- MembreResource: endpoint GET /numero/{numeroMembre}
- MembreService: méthode trouverParNumeroMembre()

Changement mot de passe:
- CompteAdherentResource: endpoint POST /auth/change-password (mobile)
- MembreKeycloakSyncService: changerMotDePasseDirectKeycloak() via API Admin Keycloak directe
- Fallback automatique si lions-user-manager indisponible

Workflow:
- Flyway V17-V23: rôles, types org, formules Option C, lifecycle columns, bareme cotisation
- Nouvelles classes: MemberLifecycleService, OrganisationModuleService, scheduler
- Security: OrganisationContextFilter, OrganisationContextHolder, ModuleAccessFilter
This commit is contained in:
dahoud
2026-04-07 20:52:26 +00:00
parent c74ae25ad6
commit a2dfae9a0b
78 changed files with 5637 additions and 271 deletions

View File

@@ -0,0 +1,154 @@
package dev.lions.unionflow.server.service;
import dev.lions.unionflow.server.entity.BaremeCotisationRole;
import dev.lions.unionflow.server.entity.Cotisation;
import dev.lions.unionflow.server.entity.MembreOrganisation;
import dev.lions.unionflow.server.entity.Organisation;
import dev.lions.unionflow.server.entity.ParametresCotisationOrganisation;
import dev.lions.unionflow.server.repository.BaremeCotisationRoleRepository;
import dev.lions.unionflow.server.repository.CotisationRepository;
import dev.lions.unionflow.server.repository.MembreOrganisationRepository;
import dev.lions.unionflow.server.repository.ParametresCotisationOrganisationRepository;
import io.quarkus.scheduler.Scheduled;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
import lombok.extern.slf4j.Slf4j;
/**
* Génération automatique mensuelle des cotisations pour les organisations
* ayant activé {@code generationAutomatiqueActivee} dans leurs paramètres.
*
* <p>Le job s'exécute le 1er de chaque mois à 1h00. Pour chaque membre actif,
* il détermine le montant selon le barème du rôle fonctionnel de ce membre dans
* l'organisation, ou utilise le montant par défaut si aucun barème n'est configuré.
*
* <p>Les cotisations déjà générées pour le même mois/année sont ignorées (idempotence).
*/
@ApplicationScoped
@Slf4j
public class CotisationAutoGenerationService {
@Inject
ParametresCotisationOrganisationRepository parametresRepository;
@Inject
MembreOrganisationRepository membreOrganisationRepository;
@Inject
BaremeCotisationRoleRepository baremeRepository;
@Inject
CotisationRepository cotisationRepository;
/**
* Point d'entrée du job planifié : 1er de chaque mois à 01:00.
*/
@Scheduled(cron = "0 0 1 1 * ?")
@Transactional
public void genererCotisationsMensuelles() {
LocalDate aujourd_hui = LocalDate.now();
int annee = aujourd_hui.getYear();
int mois = aujourd_hui.getMonthValue();
log.info("=== Génération automatique cotisations {}/{} ===", mois, annee);
List<ParametresCotisationOrganisation> paramsList =
parametresRepository.findAvecGenerationAutomatiqueActivee();
if (paramsList.isEmpty()) {
log.info("Aucune organisation avec génération automatique activée.");
return;
}
int totalCrees = 0;
int totalIgnores = 0;
for (ParametresCotisationOrganisation params : paramsList) {
Organisation org = params.getOrganisation();
int[] result = genererPourOrganisation(org, params, annee, mois);
totalCrees += result[0];
totalIgnores += result[1];
}
log.info("=== Génération terminée : {} créées, {} ignorées (déjà existantes) ===",
totalCrees, totalIgnores);
}
/**
* Génère les cotisations pour une organisation donnée.
*
* @return tableau [nbCreees, nbIgnorees]
*/
int[] genererPourOrganisation(Organisation org,
ParametresCotisationOrganisation params,
int annee, int mois) {
List<MembreOrganisation> membresActifs =
membreOrganisationRepository.findMembresActifsParOrganisation(org.getId());
int crees = 0;
int ignores = 0;
String devise = params.getDevise() != null ? params.getDevise() : "XOF";
for (MembreOrganisation mo : membresActifs) {
if (mo.getMembre() == null) continue;
// Idempotence : ne pas recréer si déjà générée ce mois
if (cotisationRepository.existsByMembreOrganisationAnneeAndMois(
mo.getMembre().getId(), org.getId(), annee, mois)) {
ignores++;
continue;
}
BigDecimal montant = resoudreMontantMensuel(org.getId(), mo.getRoleOrg(), params);
if (montant == null || montant.compareTo(BigDecimal.ZERO) == 0) {
log.debug("Org {} membre {} : montant 0, cotisation ignorée", org.getNom(), mo.getMembre().getId());
ignores++;
continue;
}
LocalDate echeance = LocalDate.of(annee, mois, 28);
Cotisation cotisation = Cotisation.builder()
.typeCotisation("MENSUELLE")
.libelle("Cotisation mensuelle " + String.format("%02d/%d", mois, annee))
.description("Génération automatique — " + org.getNom())
.montantDu(montant)
.montantPaye(BigDecimal.ZERO)
.codeDevise(devise)
.statut("EN_ATTENTE")
.dateEcheance(echeance)
.annee(annee)
.mois(mois)
.recurrente(true)
.membre(mo.getMembre())
.organisation(org)
.build();
cotisation.setNumeroReference(Cotisation.genererNumeroReference());
cotisationRepository.persist(cotisation);
crees++;
}
log.info("Org '{}' [{}/{}] : {} cotisation(s) créée(s), {} ignorée(s)",
org.getNom(), mois, annee, crees, ignores);
return new int[]{crees, ignores};
}
/**
* Détermine le montant mensuel applicable à ce membre selon son rôle.
* Priorité : barème du rôle → montant par défaut de l'organisation.
*/
private BigDecimal resoudreMontantMensuel(java.util.UUID orgId, String roleOrg,
ParametresCotisationOrganisation params) {
if (roleOrg != null && !roleOrg.isBlank()) {
Optional<BaremeCotisationRole> bareme =
baremeRepository.findByOrganisationIdAndRoleOrg(orgId, roleOrg);
if (bareme.isPresent() && bareme.get().getMontantMensuel() != null) {
return bareme.get().getMontantMensuel();
}
}
return params.getMontantCotisationMensuelle();
}
}

View File

@@ -70,17 +70,17 @@ public class CotisationService {
* @param size taille de la page
* @return liste des cotisations converties en Summary Response
*/
public List<CotisationSummaryResponse> getAllCotisations(int page, int size) {
public List<CotisationResponse> getAllCotisations(int page, int size) {
log.debug("Récupération des cotisations - page: {}, size: {}", page, size);
jakarta.persistence.TypedQuery<Cotisation> query = cotisationRepository.getEntityManager().createQuery(
"SELECT c FROM Cotisation c ORDER BY c.dateEcheance DESC",
"SELECT c FROM Cotisation c LEFT JOIN FETCH c.membre LEFT JOIN FETCH c.organisation ORDER BY c.dateEcheance DESC",
Cotisation.class);
query.setFirstResult(page * size);
query.setMaxResults(size);
List<Cotisation> cotisations = query.getResultList();
return cotisations.stream().map(this::convertToSummaryResponse).collect(Collectors.toList());
return cotisations.stream().map(this::convertToResponse).collect(Collectors.toList());
}
/**
@@ -283,7 +283,7 @@ public class CotisationService {
/**
* Récupère les cotisations d'un membre.
*/
public List<CotisationSummaryResponse> getCotisationsByMembre(@NotNull UUID membreId, int page, int size) {
public List<CotisationResponse> getCotisationsByMembre(@NotNull UUID membreId, int page, int size) {
log.debug("Récupération des cotisations du membre: {}", membreId);
if (!membreRepository.findByIdOptional(membreId).isPresent()) {
@@ -293,35 +293,35 @@ public class CotisationService {
List<Cotisation> cotisations = cotisationRepository.findByMembreId(
membreId, Page.of(page, size), Sort.by("dateEcheance").descending());
return cotisations.stream().map(this::convertToSummaryResponse).collect(Collectors.toList());
return cotisations.stream().map(this::convertToResponse).collect(Collectors.toList());
}
/**
* Récupère les cotisations par statut.
*/
public List<CotisationSummaryResponse> getCotisationsByStatut(@NotNull String statut, int page, int size) {
public List<CotisationResponse> getCotisationsByStatut(@NotNull String statut, int page, int size) {
log.debug("Récupération des cotisations avec statut: {}", statut);
List<Cotisation> cotisations = cotisationRepository.findByStatut(statut, Page.of(page, size));
return cotisations.stream().map(this::convertToSummaryResponse).collect(Collectors.toList());
return cotisations.stream().map(this::convertToResponse).collect(Collectors.toList());
}
/**
* Récupère les cotisations en retard.
*/
public List<CotisationSummaryResponse> getCotisationsEnRetard(int page, int size) {
public List<CotisationResponse> getCotisationsEnRetard(int page, int size) {
log.debug("Récupération des cotisations en retard");
List<Cotisation> cotisations = cotisationRepository.findCotisationsEnRetard(LocalDate.now(), Page.of(page, size));
return cotisations.stream().map(this::convertToSummaryResponse).collect(Collectors.toList());
return cotisations.stream().map(this::convertToResponse).collect(Collectors.toList());
}
/**
* Recherche avancée de cotisations.
*/
public List<CotisationSummaryResponse> rechercherCotisations(
public List<CotisationResponse> rechercherCotisations(
UUID membreId,
String statut,
String typeCotisation,
@@ -334,7 +334,7 @@ public class CotisationService {
List<Cotisation> cotisations = cotisationRepository.rechercheAvancee(
membreId, statut, typeCotisation, annee, mois, Page.of(page, size));
return cotisations.stream().map(this::convertToSummaryResponse).collect(Collectors.toList());
return cotisations.stream().map(this::convertToResponse).collect(Collectors.toList());
}
/**
@@ -699,7 +699,7 @@ public class CotisationService {
* Toutes les cotisations du membre connecté (tous statuts), ou des organisations gérées si ADMIN/ADMIN_ORGANISATION.
* Utilisé pour les onglets Toutes / Payées / Dues / Retard.
*/
public List<CotisationSummaryResponse> getMesCotisations(int page, int size) {
public List<CotisationResponse> getMesCotisations(int page, int size) {
String email = securiteHelper.resolveEmail();
if (email == null || email.isBlank()) {
return Collections.emptyList();
@@ -714,7 +714,7 @@ public class CotisationService {
Set<UUID> orgIds = orgs.stream().map(Organisation::getId).collect(Collectors.toCollection(LinkedHashSet::new));
List<Cotisation> cotisations = cotisationRepository.findByOrganisationIdIn(
orgIds, Page.of(page, size), Sort.by("dateEcheance").descending());
return cotisations.stream().map(this::convertToSummaryResponse).collect(Collectors.toList());
return cotisations.stream().map(this::convertToResponse).collect(Collectors.toList());
}
Membre membreConnecte = membreRepository.findByEmail(email).orElse(null);
if (membreConnecte == null) {
@@ -731,7 +731,7 @@ public class CotisationService {
*
* @return Liste des cotisations en attente
*/
public List<CotisationSummaryResponse> getMesCotisationsEnAttente() {
public List<CotisationResponse> getMesCotisationsEnAttente() {
String email = securiteHelper.resolveEmail();
if (email == null || email.isBlank()) {
return Collections.emptyList();
@@ -745,7 +745,7 @@ public class CotisationService {
Set<UUID> orgIds = orgs.stream().map(Organisation::getId).collect(Collectors.toCollection(LinkedHashSet::new));
List<Cotisation> cotisations = cotisationRepository.findEnAttenteByOrganisationIdIn(orgIds);
log.info("Cotisations en attente (admin): {} pour {} organisations", cotisations.size(), orgIds.size());
return cotisations.stream().map(this::convertToSummaryResponse).collect(Collectors.toList());
return cotisations.stream().map(this::convertToResponse).collect(Collectors.toList());
}
Membre membreConnecte = membreRepository.findByEmail(email).orElse(null);
if (membreConnecte == null) {
@@ -758,6 +758,7 @@ public class CotisationService {
List<Cotisation> cotisations = cotisationRepository.getEntityManager()
.createQuery(
"SELECT c FROM Cotisation c " +
"LEFT JOIN FETCH c.membre LEFT JOIN FETCH c.organisation " +
"WHERE c.membre.id = :membreId " +
"AND c.statut = 'EN_ATTENTE' " +
"AND EXTRACT(YEAR FROM c.dateEcheance) = :annee " +
@@ -769,7 +770,7 @@ public class CotisationService {
log.info("Cotisations en attente trouvées: {} pour le membre {}",
cotisations.size(), membreConnecte.getNumeroMembre());
return cotisations.stream()
.map(this::convertToSummaryResponse)
.map(this::convertToResponse)
.collect(Collectors.toList());
}

View File

@@ -13,6 +13,8 @@ import dev.lions.unionflow.server.mapper.DemandeAideMapper;
import dev.lions.unionflow.server.repository.DemandeAideRepository;
import dev.lions.unionflow.server.repository.MembreRepository;
import dev.lions.unionflow.server.repository.OrganisationRepository;
import io.quarkus.panache.common.Page;
import io.quarkus.panache.common.Sort;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
@@ -403,9 +405,19 @@ public class DemandeAideService {
cacheDemandesRecentes.keySet().retainAll(cacheTimestamps.keySet());
}
/** Charge toutes les demandes depuis la base et les mappe en DTO. */
/**
* Charge les demandes depuis la base avec une limite de 1000 enregistrements.
* Log un avertissement si le résultat dépasse 500 éléments pour anticiper les risques OOM.
*/
private List<DemandeAideResponse> chargerToutesLesDemandesDepuisBDD() {
List<DemandeAide> entities = demandeAideRepository.listAll();
int limite = 1000;
List<DemandeAide> entities = demandeAideRepository.findAll(
Page.ofSize(limite),
Sort.by("dateDemande", Sort.Direction.Descending)
);
if (entities.size() > 500) {
LOG.warnf("chargerToutesLesDemandesDepuisBDD : %d demandes chargées en mémoire — risque OOM si la volumétrie continue de croître", entities.size());
}
return entities.stream()
.map(demandeAideMapper::toDTO)
.collect(Collectors.toList());

View File

@@ -122,6 +122,19 @@ public class DocumentService {
return convertToResponse(pieceJointe);
}
/**
* Liste les documents de l'utilisateur connecté
*
* @return Liste des documents créés par l'utilisateur connecté
*/
public List<DocumentResponse> listerMesDocuments() {
String email = keycloakService.getCurrentUserEmail();
LOG.infof("Listing des documents pour l'utilisateur: %s", email);
return documentRepository.findByCreePar(email).stream()
.map(this::convertToResponse)
.collect(Collectors.toList());
}
/**
* Liste toutes les pièces jointes d'un document
*

View File

@@ -0,0 +1,319 @@
package dev.lions.unionflow.server.service;
import dev.lions.unionflow.server.api.dto.notification.request.CreateNotificationRequest;
import dev.lions.unionflow.server.api.enums.membre.StatutMembre;
import dev.lions.unionflow.server.entity.Membre;
import dev.lions.unionflow.server.entity.MembreOrganisation;
import dev.lions.unionflow.server.repository.MembreOrganisationRepository;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
import org.jboss.logging.Logger;
/**
* Service de cycle de vie des membres au sein d'une organisation.
*
* <p>Gère les transitions de statut automatiques et manuelles :
* <ul>
* <li>Invitation → EN_ATTENTE_VALIDATION (après acceptation)</li>
* <li>EN_ATTENTE_VALIDATION → ACTIF (après validation admin)</li>
* <li>ACTIF → SUSPENDU (suspension manuelle ou automatique)</li>
* <li>INVITE → expiré (si la date d'expiration est dépassée)</li>
* <li>ACTIF → ARCHIVE (archivage)</li>
* </ul>
*
* <p>Les tâches automatiques sont déclenchées par {@link MemberLifecycleScheduler}.
*/
@ApplicationScoped
public class MemberLifecycleService {
private static final Logger LOG = Logger.getLogger(MemberLifecycleService.class);
/** Durée de validité par défaut d'une invitation (7 jours). */
public static final int INVITATION_EXPIRY_DAYS = 7;
/** Délai de rappel avant expiration (24h avant). */
public static final int INVITATION_REMINDER_HOURS = 24;
@Inject
MembreOrganisationRepository membreOrgRepository;
@Inject
NotificationService notificationService;
// =========================================================================
// Transitions manuelles
// =========================================================================
/**
* Active un membre dans une organisation (transition → ACTIF).
*
* @param membreOrgId UUID du lien membre-organisation
* @param adminId UUID de l'administrateur qui valide
* @param motif Motif optionnel de l'activation
*/
@Transactional
public MembreOrganisation activerMembre(UUID membreOrgId, UUID adminId, String motif) {
MembreOrganisation lien = charger(membreOrgId);
verifierTransitionAutorisee(lien.getStatutMembre(),
StatutMembre.EN_ATTENTE_VALIDATION, StatutMembre.INVITE, StatutMembre.SUSPENDU);
lien.setStatutMembre(StatutMembre.ACTIF);
lien.setDateChangementStatut(LocalDate.now());
lien.setMotifStatut(motif != null ? motif : "Validation par l'administrateur");
membreOrgRepository.persist(lien);
// Mettre à jour le compteur de membres de l'organisation
lien.getOrganisation().ajouterMembre();
LOG.infof("Membre %s activé dans organisation %s par admin %s",
lien.getMembre().getId(), lien.getOrganisation().getId(), adminId);
// Notifier le membre
envoyerNotification(lien.getMembre(), "Adhésion activée",
"Votre adhésion à " + lien.getOrganisation().getNom() + " est maintenant active.");
return lien;
}
/**
* Suspend un membre dans une organisation (ACTIF → SUSPENDU).
*/
@Transactional
public MembreOrganisation suspendreMembre(UUID membreOrgId, UUID adminId, String motif) {
MembreOrganisation lien = charger(membreOrgId);
verifierTransitionAutorisee(lien.getStatutMembre(), StatutMembre.ACTIF);
lien.setStatutMembre(StatutMembre.SUSPENDU);
lien.setDateChangementStatut(LocalDate.now());
lien.setMotifStatut(motif != null ? motif : "Suspension par l'administrateur");
membreOrgRepository.persist(lien);
LOG.infof("Membre %s suspendu dans organisation %s", lien.getMembre().getId(),
lien.getOrganisation().getId());
envoyerNotification(lien.getMembre(), "Adhésion suspendue",
"Votre adhésion à " + lien.getOrganisation().getNom() + " a été suspendue. Motif : " + motif);
return lien;
}
/**
* Radie un membre d'une organisation (→ RADIE).
*/
@Transactional
public MembreOrganisation radierMembre(UUID membreOrgId, UUID adminId, String motif) {
MembreOrganisation lien = charger(membreOrgId);
lien.setStatutMembre(StatutMembre.RADIE);
lien.setDateChangementStatut(LocalDate.now());
lien.setMotifStatut(motif != null ? motif : "Radiation par l'administrateur");
membreOrgRepository.persist(lien);
// Mettre à jour le compteur de membres de l'organisation
lien.getOrganisation().retirerMembre();
LOG.infof("Membre %s radié de l'organisation %s. Motif: %s",
lien.getMembre().getId(), lien.getOrganisation().getId(), motif);
envoyerNotification(lien.getMembre(), "Adhésion radiée",
"Votre adhésion à " + lien.getOrganisation().getNom() + " a été radiée.");
return lien;
}
/**
* Archive un membre (→ ARCHIVE) sans supprimer l'historique.
*/
@Transactional
public MembreOrganisation archiverMembre(UUID membreOrgId, String motif) {
MembreOrganisation lien = charger(membreOrgId);
lien.setStatutMembre(StatutMembre.ARCHIVE);
lien.setDateChangementStatut(LocalDate.now());
lien.setMotifArchivage(motif);
membreOrgRepository.persist(lien);
// Mettre à jour le compteur de membres de l'organisation
lien.getOrganisation().retirerMembre();
LOG.infof("Membre %s archivé dans l'organisation %s", lien.getMembre().getId(),
lien.getOrganisation().getId());
return lien;
}
/**
* Crée une invitation (statut INVITE) pour un nouveau membre.
*
* @param membre Le membre à inviter (doit exister en base)
* @param organisation L'organisation cible
* @param adminId L'administrateur qui invite
* @param roleOrg Rôle proposé dans l'organisation (optionnel)
* @return le lien MembreOrganisation créé avec le token d'invitation
*/
@Transactional
public MembreOrganisation inviterMembre(
dev.lions.unionflow.server.entity.Membre membre,
dev.lions.unionflow.server.entity.Organisation organisation,
UUID adminId,
String roleOrg) {
// Vérifier s'il n'existe pas déjà un lien actif
membreOrgRepository.findByMembreIdAndOrganisationId(membre.getId(), organisation.getId())
.ifPresent(existing -> {
throw new IllegalStateException("Le membre est déjà lié à cette organisation.");
});
String token = UUID.randomUUID().toString().replace("-", "");
LocalDateTime now = LocalDateTime.now();
MembreOrganisation lien = MembreOrganisation.builder()
.membre(membre)
.organisation(organisation)
.statutMembre(StatutMembre.INVITE)
.dateInvitation(now)
.dateExpirationInvitation(now.plusDays(INVITATION_EXPIRY_DAYS))
.tokenInvitation(token)
.invitePar(adminId)
.roleOrg(roleOrg)
.build();
membreOrgRepository.persist(lien);
LOG.infof("Invitation créée pour membre %s dans organisation %s (expire: %s)",
membre.getId(), organisation.getId(), lien.getDateExpirationInvitation());
// Notifier le membre
envoyerNotification(membre, "Invitation à rejoindre " + organisation.getNom(),
"Vous avez été invité à rejoindre " + organisation.getNom() +
". Votre invitation expire dans " + INVITATION_EXPIRY_DAYS + " jours.");
return lien;
}
/**
* Accepte une invitation via son token (INVITE → EN_ATTENTE_VALIDATION).
*/
@Transactional
public MembreOrganisation accepterInvitation(String token) {
MembreOrganisation lien = membreOrgRepository
.find("tokenInvitation = ?1 and statutMembre = ?2", token, StatutMembre.INVITE)
.firstResultOptional()
.orElseThrow(() -> new IllegalArgumentException("Invitation introuvable ou déjà utilisée."));
if (lien.getDateExpirationInvitation() != null &&
lien.getDateExpirationInvitation().isBefore(LocalDateTime.now())) {
throw new IllegalStateException("Cette invitation a expiré.");
}
lien.setStatutMembre(StatutMembre.EN_ATTENTE_VALIDATION);
lien.setDateChangementStatut(LocalDate.now());
lien.setTokenInvitation(null); // Invalider le token après usage
membreOrgRepository.persist(lien);
LOG.infof("Invitation acceptée par membre %s dans organisation %s",
lien.getMembre().getId(), lien.getOrganisation().getId());
return lien;
}
// =========================================================================
// Traitements automatiques (appelés par MemberLifecycleScheduler)
// =========================================================================
/**
* Expire les invitations dont la date limite est dépassée.
* Les membres INVITE passent au statut RADIE.
*
* @return nombre d'invitations expirées
*/
@Transactional
public int expirerInvitations() {
LocalDateTime now = LocalDateTime.now();
List<MembreOrganisation> expiredInvitations = membreOrgRepository.findInvitationsExpirees(now);
int count = 0;
for (MembreOrganisation lien : expiredInvitations) {
lien.setStatutMembre(StatutMembre.RADIE);
lien.setDateChangementStatut(LocalDate.now());
lien.setMotifStatut("Invitation expirée sans réponse");
membreOrgRepository.persist(lien);
count++;
LOG.infof("Invitation expirée : membre %s dans org %s",
lien.getMembre().getId(), lien.getOrganisation().getId());
}
if (count > 0) {
LOG.infof("Expiration des invitations : %d invitation(s) expirée(s)", count);
}
return count;
}
/**
* Envoie des rappels pour les invitations qui expirent dans les prochaines 24h.
*
* @return nombre de rappels envoyés
*/
@Transactional
public int envoyerRappelsInvitation() {
LocalDateTime now = LocalDateTime.now();
LocalDateTime dans24h = now.plusHours(INVITATION_REMINDER_HOURS);
List<MembreOrganisation> aRappeler =
membreOrgRepository.findInvitationsExpirantBientot(now, dans24h);
int count = 0;
for (MembreOrganisation lien : aRappeler) {
envoyerNotification(lien.getMembre(),
"Rappel : votre invitation expire bientôt",
"Votre invitation à rejoindre " + lien.getOrganisation().getNom() +
" expire dans moins de 24h. Acceptez-la maintenant.");
count++;
}
if (count > 0) {
LOG.infof("Rappels d'invitation : %d rappel(s) envoyé(s)", count);
}
return count;
}
// =========================================================================
// Helpers privés
// =========================================================================
private MembreOrganisation charger(UUID membreOrgId) {
return membreOrgRepository.findByIdOptional(membreOrgId)
.orElseThrow(() -> new IllegalArgumentException(
"Lien membre-organisation introuvable : " + membreOrgId));
}
private void verifierTransitionAutorisee(StatutMembre current, StatutMembre... autorises) {
for (StatutMembre autorise : autorises) {
if (autorise.equals(current)) return;
}
throw new IllegalStateException(
"Transition non autorisée depuis le statut " + current);
}
private void envoyerNotification(Membre membre, String titre, String corps) {
try {
if (notificationService != null) {
CreateNotificationRequest req = CreateNotificationRequest.builder()
.typeNotification("SYSTEME")
.priorite("NORMALE")
.sujet(titre)
.corps(corps)
.membreId(membre.getId())
.build();
notificationService.creerNotification(req);
}
} catch (Exception e) {
LOG.warnf("Impossible d'envoyer la notification à %s : %s", membre.getId(), e.getMessage());
}
}
}

View File

@@ -7,12 +7,19 @@ import dev.lions.unionflow.server.entity.Membre;
import dev.lions.unionflow.server.repository.MembreRepository;
import dev.lions.user.manager.dto.user.UserDTO;
import dev.lions.user.manager.dto.user.UserSearchCriteriaDTO;
import io.quarkus.oidc.client.NamedOidcClient;
import io.quarkus.oidc.client.OidcClient;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import jakarta.ws.rs.NotFoundException;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.eclipse.microprofile.rest.client.inject.RestClient;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
@@ -60,6 +67,14 @@ public class MembreKeycloakSyncService {
private static final Logger LOGGER = Logger.getLogger(MembreKeycloakSyncService.class.getName());
private static final String DEFAULT_REALM = "unionflow";
/** URL du serveur Keycloak. Ex : https://security.lions.dev/realms/unionflow */
@ConfigProperty(name = "quarkus.oidc.auth-server-url")
String oidcAuthServerUrl;
@Inject
@NamedOidcClient("admin-service")
OidcClient adminOidcClient;
@Inject
MembreRepository membreRepository;
@@ -518,7 +533,15 @@ public class MembreKeycloakSyncService {
.temporary(false)
.build();
userServiceClient.resetPassword(keycloakUserId, DEFAULT_REALM, resetRequest);
// Tentative via lions-user-manager ; fallback sur l'API Admin Keycloak directe si 403
try {
userServiceClient.resetPassword(keycloakUserId, DEFAULT_REALM, resetRequest);
} catch (jakarta.ws.rs.ForbiddenException | jakarta.ws.rs.ServiceUnavailableException e) {
LOGGER.warning("lions-user-manager reset-password échoué (" + e.getMessage()
+ "), fallback sur API Admin Keycloak directe.");
changerMotDePasseDirectKeycloak(membre.getId(), nouveauMotDePasse);
return; // changerMotDePasseDirectKeycloak persiste déjà les flags
}
membre.setPremiereConnexion(false);
// Auto-activation : le membre prouve son identité en changeant son mot de passe temporaire
@@ -541,6 +564,75 @@ public class MembreKeycloakSyncService {
LOGGER.info("Mot de passe premier login changé pour: " + membre.getEmail());
}
/**
* Change le mot de passe d'un membre en appelant directement l'API Admin Keycloak.
* Bypass lions-user-manager (évite les erreurs 403 de service account).
*
* @param membreId UUID du membre UnionFlow
* @param nouveauMotDePasse Nouveau mot de passe (en clair, transmis en HTTPS)
*/
@Transactional
public void changerMotDePasseDirectKeycloak(UUID membreId, String nouveauMotDePasse) {
LOGGER.info("Changement de mot de passe (direct Keycloak) pour membre ID: " + membreId);
Membre membre = membreRepository.findByIdOptional(membreId)
.orElseThrow(() -> new NotFoundException("Membre non trouvé: " + membreId));
if (membre.getKeycloakId() == null) {
throw new IllegalStateException("Le membre n'a pas de compte Keycloak: " + membre.getEmail());
}
String keycloakUserId = membre.getKeycloakId().toString();
// Obtenir le token admin via OIDC client credentials
String adminToken;
try {
adminToken = adminOidcClient.getTokens().await().indefinitely().getAccessToken();
} catch (Exception e) {
throw new RuntimeException("Impossible d'obtenir le token admin Keycloak: " + e.getMessage(), e);
}
// Dériver l'URL Admin Keycloak depuis l'URL OIDC
// Ex: https://security.lions.dev/realms/unionflow → https://security.lions.dev/admin/realms/unionflow
String adminUrl = oidcAuthServerUrl.replace("/realms/", "/admin/realms/")
+ "/users/" + keycloakUserId + "/reset-password";
String body = String.format(
"{\"type\":\"password\",\"value\":\"%s\",\"temporary\":false}",
nouveauMotDePasse.replace("\"", "\\\""));
try {
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(adminUrl))
.header("Authorization", "Bearer " + adminToken)
.header("Content-Type", "application/json")
.PUT(HttpRequest.BodyPublishers.ofString(body))
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 204 && response.statusCode() != 200) {
throw new RuntimeException("Keycloak Admin API retourné " + response.statusCode()
+ ": " + response.body());
}
} catch (RuntimeException e) {
throw e;
} catch (Exception e) {
throw new RuntimeException("Erreur lors de la réinitialisation du mot de passe Keycloak: " + e.getMessage(), e);
}
// Mettre à jour les flags
membre.setPremiereConnexion(false);
if ("EN_ATTENTE_VALIDATION".equals(membre.getStatutCompte())) {
membre.setStatutCompte("ACTIF");
membre.setActif(true);
}
membreRepository.persist(membre);
LOGGER.info("Mot de passe changé (direct Keycloak) pour: " + membre.getEmail());
}
/**
* Marque le premier login comme complété.
*

View File

@@ -278,6 +278,11 @@ public class MembreService {
return membreRepository.findByEmail(email);
}
/** Trouve un membre par son numéro de membre (ex: MBR-0001) */
public Optional<Membre> trouverParNumeroMembre(String numeroMembre) {
return membreRepository.findByNumeroMembre(numeroMembre);
}
/** Liste tous les membres actifs */
public List<Membre> listerMembresActifs() {
return membreRepository.findAllActifs();
@@ -523,12 +528,14 @@ public class MembreService {
UUID organisationId = null;
String organisationNom = null;
java.time.LocalDate dateAdhesion = null;
if (membre.getMembresOrganisations() != null && !membre.getMembresOrganisations().isEmpty()) {
dev.lions.unionflow.server.entity.MembreOrganisation mo = membre.getMembresOrganisations().get(0);
if (mo.getOrganisation() != null) {
organisationId = mo.getOrganisation().getId();
organisationNom = mo.getOrganisation().getNom();
}
dateAdhesion = mo.getDateAdhesion();
}
return new MembreSummaryResponse(
@@ -545,7 +552,8 @@ public class MembreService {
membre.getActif(),
rolesNames,
organisationId,
organisationNom);
organisationNom,
dateAdhesion);
}
/** Convertit un CreateMembreRequest en entité Membre */

View File

@@ -0,0 +1,157 @@
package dev.lions.unionflow.server.service;
import dev.lions.unionflow.server.entity.Organisation;
import dev.lions.unionflow.server.repository.OrganisationRepository;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import java.util.Arrays;
import java.util.Collections;
import java.util.EnumSet;
import java.util.LinkedHashSet;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import org.jboss.logging.Logger;
/**
* Service de gestion des modules actifs par organisation.
*
* <p>Architecture Option C — les modules actifs sont déterminés par le TYPE
* d'organisation, pas par le plan tarifaire. Le plan impacte uniquement la
* profondeur fonctionnelle (reporting, API, fédération).
*
* <p>Les modules sont regroupés en deux catégories :
* <ul>
* <li>MODULES_COMMUNS — accessibles à tous les types d'org</li>
* <li>Modules métier — spécifiques au type d'org (TONTINE, CREDIT, etc.)</li>
* </ul>
*/
@ApplicationScoped
public class OrganisationModuleService {
private static final Logger LOG = Logger.getLogger(OrganisationModuleService.class);
/** Modules présents sur toutes les organisations quelle que soit leur nature. */
public static final Set<String> MODULES_COMMUNS = Set.of(
"MEMBRES",
"COTISATIONS",
"EVENEMENTS",
"COMMUNICATION",
"DOCUMENTS",
"NOTIFICATION",
"AIDE"
);
@Inject
OrganisationRepository organisationRepository;
/**
* Retourne l'ensemble des modules actifs pour une organisation donnée.
* Combine les modules communs avec les modules métier du type d'org.
*/
public Set<String> getModulesActifs(UUID organisationId) {
Optional<Organisation> opt = organisationRepository.findByIdOptional(organisationId);
if (opt.isEmpty()) {
LOG.warnf("Organisation introuvable : %s", organisationId);
return MODULES_COMMUNS;
}
return getModulesActifs(opt.get());
}
/**
* Retourne l'ensemble des modules actifs pour une organisation.
*/
public Set<String> getModulesActifs(Organisation organisation) {
Set<String> modules = new LinkedHashSet<>(MODULES_COMMUNS);
// 1. Modules issus du champ modulesActifs persisté (calculé depuis types_reference en V18)
String modulesActifsCsv = organisation.getModulesActifs();
if (modulesActifsCsv != null && !modulesActifsCsv.isBlank()) {
Arrays.stream(modulesActifsCsv.split(","))
.map(String::trim)
.filter(s -> !s.isEmpty())
.forEach(modules::add);
return modules;
}
// 2. Fallback : déduction depuis le typeOrganisation si modulesActifs non renseigné
modules.addAll(getModulesParType(organisation.getTypeOrganisation()));
return modules;
}
/**
* Vérifie si un module spécifique est actif pour une organisation.
*/
public boolean isModuleActif(UUID organisationId, String module) {
return getModulesActifs(organisationId).contains(module.toUpperCase());
}
/**
* Retourne les modules métier associés à un type d'organisation.
* Utilisé en fallback si la colonne modules_actifs n'est pas peuplée.
*/
public Set<String> getModulesParType(String typeOrganisation) {
if (typeOrganisation == null) {
return Collections.emptySet();
}
Set<String> modules = new LinkedHashSet<>();
switch (typeOrganisation.toUpperCase()) {
case "TONTINE" -> {
modules.add("TONTINE");
modules.add("FINANCE");
}
case "MUTUELLE_EPARGNE" -> {
modules.add("EPARGNE");
modules.add("FINANCE");
modules.add("LCB_FT");
}
case "MUTUELLE_CREDIT" -> {
modules.add("EPARGNE");
modules.add("CREDIT");
modules.add("FINANCE");
modules.add("LCB_FT");
}
case "COOPERATIVE" -> {
modules.add("AGRICULTURE");
modules.add("FINANCE");
}
case "ONG", "FONDATION" -> {
modules.add("PROJETS_ONG");
modules.add("COLLECTE_FONDS");
modules.add("FINANCE");
}
case "EGLISE", "GROUPE_PRIERE" -> {
modules.add("CULTE_DONS");
}
case "SYNDICAT", "ORDRE_PROFESSIONNEL", "FEDERATION" -> {
modules.add("VOTES");
modules.add("REGISTRE_AGREMENT");
}
case "GIE" -> {
modules.add("FINANCE");
}
case "ASSOCIATION", "CLUB_SERVICE", "CLUB_SPORTIF", "CLUB_CULTUREL" -> {
modules.add("VOTES");
}
default -> LOG.debugf("Type d''organisation non reconnu pour module mapping : %s", typeOrganisation);
}
return modules;
}
/**
* Retourne la liste des modules actifs sous forme de tableau JSON-friendly.
* Utilisé par l'endpoint /api/organisations/{id}/modules-actifs
*/
public ModulesActifsResponse getModulesActifsResponse(UUID organisationId) {
Optional<Organisation> opt = organisationRepository.findByIdOptional(organisationId);
if (opt.isEmpty()) {
return new ModulesActifsResponse(organisationId, Collections.emptySet(), "UNKNOWN");
}
Organisation org = opt.get();
Set<String> modules = getModulesActifs(org);
return new ModulesActifsResponse(organisationId, modules, org.getTypeOrganisation());
}
/** DTO de réponse pour l'endpoint modules-actifs. */
public record ModulesActifsResponse(UUID organisationId, Set<String> modules, String typeOrganisation) {}
}

View File

@@ -333,7 +333,11 @@ public class PaiementService {
.build();
intentionPaiementRepository.persist(intention);
String successUrl = base + "/api/wave-redirect/success?ref=" + intention.getId();
// Web (sans numéro de téléphone) → page HTML de confirmation ; Mobile → deep link app
boolean isWebContext = request.numeroTelephone() == null || request.numeroTelephone().isBlank();
String successUrl = base + (isWebContext
? "/api/wave-redirect/web-success?ref=" + intention.getId()
: "/api/wave-redirect/success?ref=" + intention.getId());
String errorUrl = base + "/api/wave-redirect/error?ref=" + intention.getId();
String clientRef = intention.getId().toString();
// XOF : montant entier, pas de décimales (spec Wave)
@@ -393,6 +397,113 @@ public class PaiementService {
.build();
}
/**
* Vérifie le statut d'une IntentionPaiement Wave.
* Si la session Wave est complétée (paiement réussi), réconcilie automatiquement
* la cotisation (marque PAYEE) et met à jour l'intention (COMPLETEE).
* Appelé en polling depuis le web toutes les 3 secondes.
*/
@Transactional
public dev.lions.unionflow.server.api.dto.paiement.response.IntentionStatutResponse verifierStatutIntention(UUID intentionId) {
IntentionPaiement intention = intentionPaiementRepository.findById(intentionId);
if (intention == null) {
throw new NotFoundException("IntentionPaiement non trouvée: " + intentionId);
}
// Déjà terminée — retourner immédiatement
if (intention.isCompletee()) {
return buildStatutResponse(intention, "Paiement confirmé !");
}
if (StatutIntentionPaiement.EXPIREE.equals(intention.getStatut())
|| StatutIntentionPaiement.ECHOUEE.equals(intention.getStatut())) {
return buildStatutResponse(intention, "Paiement " + intention.getStatut().name().toLowerCase());
}
// Session expirée côté UnionFlow (30 min)
if (intention.isExpiree()) {
intention.setStatut(StatutIntentionPaiement.EXPIREE);
intentionPaiementRepository.persist(intention);
return buildStatutResponse(intention, "Session expirée, veuillez recommencer");
}
// Vérifier le statut côté Wave si session connue
if (intention.getWaveCheckoutSessionId() != null) {
try {
WaveCheckoutService.WaveSessionStatusResponse waveStatus =
waveCheckoutService.getSession(intention.getWaveCheckoutSessionId());
if (waveStatus.isSucceeded()) {
completerIntention(intention, waveStatus.transactionId);
return buildStatutResponse(intention, "Paiement confirmé !");
} else if (waveStatus.isExpired()) {
intention.setStatut(StatutIntentionPaiement.EXPIREE);
intentionPaiementRepository.persist(intention);
return buildStatutResponse(intention, "Session Wave expirée");
}
} catch (WaveCheckoutService.WaveCheckoutException e) {
LOG.warnf(e, "Impossible de vérifier la session Wave %s — retry au prochain poll",
intention.getWaveCheckoutSessionId());
}
}
return buildStatutResponse(intention, "En attente de confirmation Wave...");
}
/**
* Marque l'IntentionPaiement COMPLETEE et réconcilie les cotisations cibles (PAYEE).
* Utilisé par le polling web ET par WaveRedirectResource lors du redirect success.
*/
@Transactional
public void completerIntention(IntentionPaiement intention, String waveTransactionId) {
if (intention.isCompletee()) return; // idempotent
intention.setStatut(StatutIntentionPaiement.COMPLETEE);
intention.setDateCompletion(java.time.LocalDateTime.now());
if (waveTransactionId != null) intention.setWaveTransactionId(waveTransactionId);
intentionPaiementRepository.persist(intention);
// Réconcilier les cotisations listées dans objetsCibles
String objetsCibles = intention.getObjetsCibles();
if (objetsCibles == null || objetsCibles.isBlank()) return;
try {
com.fasterxml.jackson.databind.JsonNode arr =
new com.fasterxml.jackson.databind.ObjectMapper().readTree(objetsCibles);
if (!arr.isArray()) return;
for (com.fasterxml.jackson.databind.JsonNode node : arr) {
if (!"COTISATION".equals(node.path("type").asText())) continue;
UUID cotisationId = UUID.fromString(node.get("id").asText());
java.math.BigDecimal montant = node.has("montant")
? new java.math.BigDecimal(node.get("montant").asText())
: intention.getMontantTotal();
Cotisation cotisation = paiementRepository.getEntityManager().find(Cotisation.class, cotisationId);
if (cotisation == null) continue;
cotisation.setMontantPaye(montant);
cotisation.setStatut("PAYEE");
cotisation.setDatePaiement(java.time.LocalDateTime.now());
paiementRepository.getEntityManager().merge(cotisation);
LOG.infof("Cotisation %s marquée PAYEE — Wave txn %s", cotisationId, waveTransactionId);
}
} catch (Exception e) {
LOG.errorf(e, "Erreur réconciliation cotisations pour intention %s", intention.getId());
}
}
private dev.lions.unionflow.server.api.dto.paiement.response.IntentionStatutResponse buildStatutResponse(
IntentionPaiement intention, String message) {
return dev.lions.unionflow.server.api.dto.paiement.response.IntentionStatutResponse.builder()
.intentionId(intention.getId())
.statut(intention.getStatut().name())
.waveLaunchUrl(intention.getWaveLaunchUrl())
.waveCheckoutSessionId(intention.getWaveCheckoutSessionId())
.waveTransactionId(intention.getWaveTransactionId())
.montant(intention.getMontantTotal())
.message(message)
.build();
}
/** Format E.164 pour Wave (ex: 771234567 -> +225771234567). */
private static String toE164(String numeroTelephone) {
if (numeroTelephone == null || numeroTelephone.isBlank()) return null;

View File

@@ -306,6 +306,40 @@ public class SouscriptionService {
// ── Validation SuperAdmin ──────────────────────────────────────────────────
/**
* Liste toutes les souscriptions (SuperAdmin), avec filtre optionnel par organisation.
*/
public List<SouscriptionStatutResponse> listerToutes(UUID organisationId, int page, int size) {
if (organisationId != null) {
return souscriptionRepo
.find("organisation.id = ?1 order by dateCreation desc", organisationId)
.page(page, size)
.list()
.stream()
.map(s -> toStatutResponse(s, null))
.collect(Collectors.toList());
}
return souscriptionRepo
.findAll()
.page(page, size)
.list()
.stream()
.map(s -> toStatutResponse(s, null))
.collect(Collectors.toList());
}
/**
* Retourne la souscription active d'une organisation (SuperAdmin).
*/
public SouscriptionStatutResponse obtenirActiveParOrganisation(UUID organisationId) {
return souscriptionRepo
.find("organisation.id = ?1 and statut = ?2 order by dateCreation desc",
organisationId, dev.lions.unionflow.server.api.enums.abonnement.StatutSouscription.ACTIVE)
.firstResultOptional()
.map(s -> toStatutResponse(s, null))
.orElse(null);
}
/**
* Liste les souscriptions en attente de validation SuperAdmin.
*/
@@ -499,6 +533,7 @@ public class SouscriptionService {
r.setDateFin(s.getDateFin());
r.setDateValidation(s.getDateValidation());
r.setCommentaireRejet(s.getCommentaireRejet());
r.setStatut(s.getStatut() != null ? s.getStatut().name() : null);
if (s.getOrganisation() != null) {
r.setOrganisationId(s.getOrganisation().getId().toString());
r.setOrganisationNom(s.getOrganisation().getNom());

View File

@@ -58,6 +58,8 @@ public class SystemMetricsService {
private final AtomicLong apiRequestsCount = new AtomicLong(0);
private final AtomicLong apiRequestsLastHour = new AtomicLong(0);
private final AtomicLong apiRequestsToday = new AtomicLong(0);
private final AtomicLong requestCount = new AtomicLong(0);
private final AtomicLong totalResponseTime = new AtomicLong(0);
private long startTimeMillis;
private LocalDateTime startTime;
@@ -283,11 +285,15 @@ public class SystemMetricsService {
}
/**
* Nombre de sessions actives
* Nombre de sessions actives (proxy : membres avec compte ACTIF)
*/
private Integer getActiveSessionsCount() {
// TODO: Implémenter avec vrai système de sessions Keycloak
return 0;
try {
return (int) membreRepository.count("statutCompte = 'ACTIF'");
} catch (Exception e) {
log.warn("Impossible de compter les membres actifs", e);
return 0;
}
}
/**
@@ -303,11 +309,12 @@ public class SystemMetricsService {
}
/**
* Temps de réponse moyen API
* Temps de réponse moyen API (basé sur les appels enregistrés via recordRequest)
*/
private Double getAverageResponseTime() {
// TODO: Implémenter avec vrai système de métriques
return 0.0;
long count = requestCount.get();
if (count == 0) return 0.0;
return (double) totalResponseTime.get() / count;
}
/**
@@ -422,4 +429,14 @@ public class SystemMetricsService {
apiRequestsLastHour.incrementAndGet();
apiRequestsToday.incrementAndGet();
}
/**
* Enregistrer une requête avec son temps de réponse (en ms)
* Permet le calcul du temps de réponse moyen via getAverageResponseTime()
*/
public void recordRequest(long responseTimeMs) {
requestCount.incrementAndGet();
totalResponseTime.addAndGet(responseTimeMs);
incrementApiRequestCount();
}
}

View File

@@ -149,6 +149,62 @@ public class WaveCheckoutService {
return HexFormat.of().formatHex(hash);
}
/**
* Interroge l'état d'une session Wave Checkout (spec : GET /v1/checkout/sessions/:id).
* Utilisé par le polling web pour détecter automatiquement la complétion du paiement.
*
* @param sessionId ID de session Wave (cos-xxx)
* @return statut de la session (checkout_status, payment_status, transaction_id)
*/
public WaveSessionStatusResponse getSession(String sessionId) throws WaveCheckoutException {
boolean useMock = mockEnabled || apiKey == null || apiKey.trim().isBlank();
if (useMock) {
// En mock, on ne peut pas vraiment vérifier — retourner EN_COURS (polling s'arrête via /web-success)
LOG.warnf("Wave getSession en mode MOCK — session %s", sessionId);
return new WaveSessionStatusResponse(sessionId, "open", "processing", null);
}
String base = (baseUrl == null || baseUrl.endsWith("/")) ? baseUrl.replaceAll("/+$", "") : baseUrl;
if (!base.endsWith("/v1")) base = base + "/v1";
String url = base + "/checkout/sessions/" + sessionId;
try {
long timestamp = System.currentTimeMillis() / 1000;
java.net.http.HttpRequest.Builder requestBuilder = java.net.http.HttpRequest.newBuilder()
.uri(URI.create(url))
.header("Authorization", "Bearer " + apiKey)
.header("Content-Type", "application/json")
.timeout(Duration.ofSeconds(15))
.GET();
if (signingSecret != null && !signingSecret.trim().isBlank()) {
String sig = computeWaveSignature(timestamp, "");
requestBuilder.header("Wave-Signature", "t=" + timestamp + ",v1=" + sig);
}
java.net.http.HttpClient client = java.net.http.HttpClient.newBuilder().build();
java.net.http.HttpResponse<String> response = client.send(
requestBuilder.build(),
java.net.http.HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
if (response.statusCode() >= 400) {
throw new WaveCheckoutException("Wave API: " + response.statusCode() + " " + response.body());
}
JsonNode root = objectMapper.readTree(response.body());
String checkoutStatus = root.has("checkout_status") ? root.get("checkout_status").asText() : null;
String paymentStatus = root.has("payment_status") ? root.get("payment_status").asText() : null;
String transactionId = root.has("transaction_id") ? root.get("transaction_id").asText() : null;
return new WaveSessionStatusResponse(sessionId, checkoutStatus, paymentStatus, transactionId);
} catch (WaveCheckoutException e) {
throw e;
} catch (Exception e) {
LOG.error(e.getMessage(), e);
throw new WaveCheckoutException("Erreur vérification session Wave: " + e.getMessage(), e);
}
}
public String getRedirectBaseUrl() {
return (redirectBaseUrl == null || redirectBaseUrl.trim().isBlank()) ? "http://localhost:8080" : redirectBaseUrl.trim();
}
@@ -159,6 +215,31 @@ public class WaveCheckoutService {
return new WaveCheckoutSessionResponse(mockId, successUrl);
}
public static final class WaveSessionStatusResponse {
public final String sessionId;
/** "open" | "complete" | "expired" */
public final String checkoutStatus;
/** "processing" | "cancelled" | "succeeded" */
public final String paymentStatus;
/** ID transaction Wave (TCN...) — non-null si succeeded */
public final String transactionId;
public WaveSessionStatusResponse(String sessionId, String checkoutStatus, String paymentStatus, String transactionId) {
this.sessionId = sessionId;
this.checkoutStatus = checkoutStatus;
this.paymentStatus = paymentStatus;
this.transactionId = transactionId;
}
public boolean isSucceeded() {
return "succeeded".equals(paymentStatus) && "complete".equals(checkoutStatus);
}
public boolean isExpired() {
return "expired".equals(checkoutStatus);
}
}
public static final class WaveCheckoutSessionResponse {
public final String id;
public final String waveLaunchUrl;