findByOrganisation(UUID organisationId) {
+ return list("organisationId = ?1 ORDER BY dateDebut DESC", organisationId);
+ }
}
diff --git a/src/main/java/dev/lions/unionflow/server/resource/AuditTrailOperationResource.java b/src/main/java/dev/lions/unionflow/server/resource/AuditTrailOperationResource.java
new file mode 100644
index 0000000..81a9a5e
--- /dev/null
+++ b/src/main/java/dev/lions/unionflow/server/resource/AuditTrailOperationResource.java
@@ -0,0 +1,89 @@
+package dev.lions.unionflow.server.resource;
+
+import dev.lions.unionflow.server.api.dto.audit.response.AuditTrailOperationResponse;
+import dev.lions.unionflow.server.service.audit.AuditTrailQueryService;
+import io.quarkus.security.Authenticated;
+import jakarta.annotation.security.RolesAllowed;
+import jakarta.inject.Inject;
+import jakarta.ws.rs.GET;
+import jakarta.ws.rs.Path;
+import jakarta.ws.rs.PathParam;
+import jakarta.ws.rs.Produces;
+import jakarta.ws.rs.QueryParam;
+import jakarta.ws.rs.core.MediaType;
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.UUID;
+
+/**
+ * Endpoints REST de lecture de l'audit trail enrichi (Sprint 1, exposé Sprint 10).
+ *
+ * Cible : compliance officer / contrôleur interne / SUPER_ADMIN. Seule la lecture
+ * est exposée — les écritures sont produites automatiquement par les services métier
+ * via {@code AuditTrailService}.
+ *
+ * @since 2026-04-25 (Sprint 10)
+ */
+@Path("/api/audit-trail")
+@Produces(MediaType.APPLICATION_JSON)
+@Authenticated
+public class AuditTrailOperationResource {
+
+ @Inject AuditTrailQueryService queryService;
+
+ @GET
+ @Path("/by-user/{userId}")
+ @RolesAllowed({"COMPLIANCE_OFFICER", "CONTROLEUR_INTERNE", "SUPER_ADMIN"})
+ public List parUtilisateur(
+ @PathParam("userId") UUID userId,
+ @QueryParam("from") String from,
+ @QueryParam("to") String to) {
+ LocalDateTime fromDt = parseDateTime(from, LocalDateTime.now().minusDays(30));
+ LocalDateTime toDt = parseDateTime(to, LocalDateTime.now());
+ return queryService.rechercherParUtilisateur(userId, fromDt, toDt);
+ }
+
+ @GET
+ @Path("/by-entity/{type}/{id}")
+ @RolesAllowed({"COMPLIANCE_OFFICER", "CONTROLEUR_INTERNE", "SUPER_ADMIN"})
+ public List historique(
+ @PathParam("type") String entityType,
+ @PathParam("id") UUID entityId) {
+ return queryService.historiqueEntite(entityType, entityId);
+ }
+
+ @GET
+ @Path("/by-organisation/{orgId}")
+ @RolesAllowed({"COMPLIANCE_OFFICER", "CONTROLEUR_INTERNE", "ADMIN_ORGANISATION", "SUPER_ADMIN"})
+ public List parOrganisation(@PathParam("orgId") UUID orgId) {
+ return queryService.rechercherParOrganisation(orgId);
+ }
+
+ @GET
+ @Path("/sod-violations")
+ @RolesAllowed({"COMPLIANCE_OFFICER", "CONTROLEUR_INTERNE", "SUPER_ADMIN"})
+ public List violationsSod() {
+ return queryService.violationsSod();
+ }
+
+ @GET
+ @Path("/financial/{orgId}")
+ @RolesAllowed({"COMPLIANCE_OFFICER", "CONTROLEUR_INTERNE", "TRESORIER", "SUPER_ADMIN"})
+ public List operationsFinancieres(
+ @PathParam("orgId") UUID orgId,
+ @QueryParam("from") String from,
+ @QueryParam("to") String to) {
+ LocalDateTime fromDt = parseDateTime(from, LocalDateTime.now().minusDays(90));
+ LocalDateTime toDt = parseDateTime(to, LocalDateTime.now());
+ return queryService.operationsFinancieres(orgId, fromDt, toDt);
+ }
+
+ private LocalDateTime parseDateTime(String input, LocalDateTime fallback) {
+ if (input == null || input.isBlank()) return fallback;
+ try {
+ return LocalDateTime.parse(input);
+ } catch (Exception e) {
+ return fallback;
+ }
+ }
+}
diff --git a/src/main/java/dev/lions/unionflow/server/resource/BeneficiaireEffectifResource.java b/src/main/java/dev/lions/unionflow/server/resource/BeneficiaireEffectifResource.java
new file mode 100644
index 0000000..2c59d75
--- /dev/null
+++ b/src/main/java/dev/lions/unionflow/server/resource/BeneficiaireEffectifResource.java
@@ -0,0 +1,83 @@
+package dev.lions.unionflow.server.resource;
+
+import dev.lions.unionflow.server.api.dto.kyc.request.CreateBeneficiaireEffectifRequest;
+import dev.lions.unionflow.server.api.dto.kyc.request.UpdateBeneficiaireEffectifRequest;
+import dev.lions.unionflow.server.api.dto.kyc.response.BeneficiaireEffectifResponse;
+import dev.lions.unionflow.server.service.kyc.BeneficiaireEffectifService;
+import io.quarkus.security.Authenticated;
+import jakarta.annotation.security.RolesAllowed;
+import jakarta.inject.Inject;
+import jakarta.validation.Valid;
+import jakarta.ws.rs.Consumes;
+import jakarta.ws.rs.DELETE;
+import jakarta.ws.rs.GET;
+import jakarta.ws.rs.POST;
+import jakarta.ws.rs.PUT;
+import jakarta.ws.rs.Path;
+import jakarta.ws.rs.PathParam;
+import jakarta.ws.rs.Produces;
+import jakarta.ws.rs.QueryParam;
+import jakarta.ws.rs.core.MediaType;
+import jakarta.ws.rs.core.Response;
+import java.util.List;
+import java.util.UUID;
+
+/**
+ * Endpoints REST des Bénéficiaires Effectifs (UBO) — Instr. BCEAO 003-03-2025.
+ *
+ * Cette Resource ne fait QUE le mapping HTTP ↔ Service. Toute la logique
+ * métier (validation pourcentages, audit trail, persistence) est dans
+ * {@link BeneficiaireEffectifService}.
+ *
+ * @since 2026-04-25 (Sprint 10)
+ */
+@Path("/api/kyc/beneficiaires-effectifs")
+@Produces(MediaType.APPLICATION_JSON)
+@Consumes(MediaType.APPLICATION_JSON)
+@Authenticated
+public class BeneficiaireEffectifResource {
+
+ @Inject BeneficiaireEffectifService service;
+
+ @GET
+ @RolesAllowed({"COMPLIANCE_OFFICER", "CONTROLEUR_INTERNE", "ADMIN_ORGANISATION", "SUPER_ADMIN"})
+ public List lister(
+ @QueryParam("kycDossierId") UUID kycDossierId,
+ @QueryParam("organisationCibleId") UUID organisationCibleId,
+ @QueryParam("pep") Boolean pep) {
+ if (Boolean.TRUE.equals(pep)) return service.listerPep();
+ if (kycDossierId != null) return service.listerParKycDossier(kycDossierId);
+ if (organisationCibleId != null) return service.listerParOrganisationCible(organisationCibleId);
+ return List.of();
+ }
+
+ @GET
+ @Path("/{id}")
+ @RolesAllowed({"COMPLIANCE_OFFICER", "CONTROLEUR_INTERNE", "ADMIN_ORGANISATION", "SUPER_ADMIN"})
+ public BeneficiaireEffectifResponse trouverParId(@PathParam("id") UUID id) {
+ return service.trouverParId(id);
+ }
+
+ @POST
+ @RolesAllowed({"COMPLIANCE_OFFICER", "ADMIN_ORGANISATION", "SUPER_ADMIN"})
+ public Response creer(@Valid CreateBeneficiaireEffectifRequest request) {
+ BeneficiaireEffectifResponse created = service.creer(request);
+ return Response.status(Response.Status.CREATED).entity(created).build();
+ }
+
+ @PUT
+ @Path("/{id}")
+ @RolesAllowed({"COMPLIANCE_OFFICER", "ADMIN_ORGANISATION", "SUPER_ADMIN"})
+ public BeneficiaireEffectifResponse mettreAJour(
+ @PathParam("id") UUID id, @Valid UpdateBeneficiaireEffectifRequest request) {
+ return service.mettreAJour(id, request);
+ }
+
+ @DELETE
+ @Path("/{id}")
+ @RolesAllowed({"COMPLIANCE_OFFICER", "ADMIN_ORGANISATION", "SUPER_ADMIN"})
+ public Response desactiver(@PathParam("id") UUID id) {
+ service.desactiver(id);
+ return Response.noContent().build();
+ }
+}
diff --git a/src/main/java/dev/lions/unionflow/server/resource/RoleDelegationResource.java b/src/main/java/dev/lions/unionflow/server/resource/RoleDelegationResource.java
new file mode 100644
index 0000000..54f138d
--- /dev/null
+++ b/src/main/java/dev/lions/unionflow/server/resource/RoleDelegationResource.java
@@ -0,0 +1,73 @@
+package dev.lions.unionflow.server.resource;
+
+import dev.lions.unionflow.server.api.dto.delegation.request.CreateRoleDelegationRequest;
+import dev.lions.unionflow.server.api.dto.delegation.response.RoleDelegationResponse;
+import dev.lions.unionflow.server.service.delegation.RoleDelegationService;
+import io.quarkus.security.identity.SecurityIdentity;
+import io.quarkus.security.Authenticated;
+import jakarta.annotation.security.RolesAllowed;
+import jakarta.inject.Inject;
+import jakarta.validation.Valid;
+import jakarta.ws.rs.Consumes;
+import jakarta.ws.rs.DELETE;
+import jakarta.ws.rs.GET;
+import jakarta.ws.rs.POST;
+import jakarta.ws.rs.Path;
+import jakarta.ws.rs.PathParam;
+import jakarta.ws.rs.Produces;
+import jakarta.ws.rs.QueryParam;
+import jakarta.ws.rs.core.MediaType;
+import jakarta.ws.rs.core.Response;
+import java.util.List;
+import java.util.Set;
+import java.util.UUID;
+
+/**
+ * Endpoints REST des délégations de rôle (Sprint 10 — service Sprint 2).
+ *
+ * La Resource ne fait que mapper HTTP ↔ {@link RoleDelegationService}. La
+ * logique SoD, validation de dates, audit trail est dans le Service.
+ *
+ *
Le set des rôles du délégataire est passé via {@code SecurityIdentity} pour
+ * vérification SoD : le client web/mobile fournit cette info via header (à terme).
+ *
+ * @since 2026-04-25 (Sprint 10)
+ */
+@Path("/api/role-delegations")
+@Produces(MediaType.APPLICATION_JSON)
+@Consumes(MediaType.APPLICATION_JSON)
+@Authenticated
+public class RoleDelegationResource {
+
+ @Inject RoleDelegationService service;
+ @Inject SecurityIdentity securityIdentity;
+
+ @GET
+ @Path("/organisation/{orgId}")
+ @RolesAllowed({"ADMIN_ORGANISATION", "PRESIDENT", "SUPER_ADMIN", "COMPLIANCE_OFFICER"})
+ public List listerParOrganisation(@PathParam("orgId") UUID orgId) {
+ return service.listerParOrganisation(orgId);
+ }
+
+ @POST
+ @RolesAllowed({"ADMIN_ORGANISATION", "PRESIDENT", "SUPER_ADMIN"})
+ public Response creer(
+ @Valid CreateRoleDelegationRequest request,
+ @QueryParam("rolesDelegataire") String rolesDelegataireCsv) {
+ Set rolesDelegataire = parseRoles(rolesDelegataireCsv);
+ RoleDelegationResponse created = service.creerDepuisRequest(request, rolesDelegataire);
+ return Response.status(Response.Status.CREATED).entity(created).build();
+ }
+
+ @DELETE
+ @Path("/{id}")
+ @RolesAllowed({"ADMIN_ORGANISATION", "PRESIDENT", "SUPER_ADMIN"})
+ public RoleDelegationResponse revoquer(@PathParam("id") UUID id) {
+ return service.revoquerEtRetourner(id);
+ }
+
+ private Set parseRoles(String csv) {
+ if (csv == null || csv.isBlank()) return Set.of();
+ return Set.of(csv.split(","));
+ }
+}
diff --git a/src/main/java/dev/lions/unionflow/server/service/OrganisationService.java b/src/main/java/dev/lions/unionflow/server/service/OrganisationService.java
index 7423456..8748283 100644
--- a/src/main/java/dev/lions/unionflow/server/service/OrganisationService.java
+++ b/src/main/java/dev/lions/unionflow/server/service/OrganisationService.java
@@ -233,6 +233,14 @@ public class OrganisationService {
// Hiérarchie
organisation.setOrganisationParente(organisationMiseAJour.getOrganisationParente());
+ // Conformité (Sprint 1) — propagation des nouveaux champs
+ if (organisationMiseAJour.getReferentielComptable() != null) {
+ organisation.setReferentielComptable(organisationMiseAJour.getReferentielComptable());
+ }
+ if (organisationMiseAJour.getComplianceOfficerId() != null) {
+ organisation.setComplianceOfficerId(organisationMiseAJour.getComplianceOfficerId());
+ }
+
organisation.marquerCommeModifie(utilisateur);
LOG.infof("Organisation mise à jour avec succès: ID=%s", id);
@@ -782,9 +790,27 @@ public class OrganisationService {
.codePostal(req.codePostal())
.organisationPublique(req.organisationPublique() != null ? req.organisationPublique() : true)
.accepteNouveauxMembres(req.accepteNouveauxMembres() != null ? req.accepteNouveauxMembres() : true)
+ .referentielComptable(parseReferentielComptable(req.referentielComptable(), req.typeOrganisation()))
+ .complianceOfficerId(req.complianceOfficerId())
.build();
}
+ /**
+ * Parse le référentiel comptable depuis la requête. Si null/vide, applique le défaut
+ * basé sur le type d'organisation (Sprint 1 — multi-référentiel).
+ */
+ private dev.lions.unionflow.server.entity.ReferentielComptable parseReferentielComptable(
+ String input, String typeOrganisation) {
+ if (input == null || input.isBlank()) {
+ return dev.lions.unionflow.server.entity.ReferentielComptable.defaultFor(typeOrganisation);
+ }
+ try {
+ return dev.lions.unionflow.server.entity.ReferentielComptable.valueOf(input);
+ } catch (IllegalArgumentException e) {
+ return dev.lions.unionflow.server.entity.ReferentielComptable.defaultFor(typeOrganisation);
+ }
+ }
+
/**
* Retourne la liste des organisations d'un membre (pour le sélecteur multi-org).
* Inclut les infos nécessaires au sélecteur : id, nom, type, catégorie, modules, rôle du membre.
diff --git a/src/main/java/dev/lions/unionflow/server/service/audit/AuditTrailQueryService.java b/src/main/java/dev/lions/unionflow/server/service/audit/AuditTrailQueryService.java
new file mode 100644
index 0000000..1e4d1f6
--- /dev/null
+++ b/src/main/java/dev/lions/unionflow/server/service/audit/AuditTrailQueryService.java
@@ -0,0 +1,81 @@
+package dev.lions.unionflow.server.service.audit;
+
+import dev.lions.unionflow.server.api.dto.audit.response.AuditTrailOperationResponse;
+import dev.lions.unionflow.server.entity.AuditTrailOperation;
+import dev.lions.unionflow.server.repository.AuditTrailOperationRepository;
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.inject.Inject;
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.UUID;
+
+/**
+ * Service de lecture (CQRS read-side) de l'audit trail enrichi.
+ *
+ * Distinct de {@code AuditTrailService} (write-side dans le package security) qui
+ * écrit les entrées d'audit. Ici on ne fait QUE lire et mapper vers DTO. Single
+ * Responsibility = consultation côté compliance officer / contrôleur interne.
+ *
+ * @since 2026-04-25 (Sprint 10)
+ */
+@ApplicationScoped
+public class AuditTrailQueryService {
+
+ @Inject AuditTrailOperationRepository repository;
+
+ /** Opérations d'un utilisateur dans une fenêtre temporelle. */
+ public List rechercherParUtilisateur(
+ UUID userId, LocalDateTime from, LocalDateTime to) {
+ return repository.findByUserBetween(userId, from, to).stream().map(this::toResponse).toList();
+ }
+
+ /** Historique d'une entité (toutes opérations CRUD/VALIDATE/EXPORT). */
+ public List historiqueEntite(String entityType, UUID entityId) {
+ return repository.findByEntity(entityType, entityId).stream().map(this::toResponse).toList();
+ }
+
+ /** Opérations dans le contexte d'une organisation. */
+ public List rechercherParOrganisation(UUID organisationActiveId) {
+ return repository.findByOrganisationActive(organisationActiveId).stream()
+ .map(this::toResponse).toList();
+ }
+
+ /** Toutes les violations de séparation des pouvoirs détectées. */
+ public List violationsSod() {
+ return repository.findSoDViolations().stream().map(this::toResponse).toList();
+ }
+
+ /** Opérations financières d'une organisation sur une période — utile reporting AIRMS. */
+ public List operationsFinancieres(
+ UUID organisationId, LocalDateTime from, LocalDateTime to) {
+ return repository.findFinancialOperations(organisationId, from, to).stream()
+ .map(this::toResponse).toList();
+ }
+
+ // ────────────────────────────────────────────────────────────
+ // Mapping Entity ↔ DTO
+ // ────────────────────────────────────────────────────────────
+
+ AuditTrailOperationResponse toResponse(AuditTrailOperation e) {
+ return AuditTrailOperationResponse.builder()
+ .id(e.getId())
+ .userId(e.getUserId())
+ .userEmail(e.getUserEmail())
+ .roleActif(e.getRoleActif())
+ .organisationActiveId(e.getOrganisationActiveId())
+ .actionType(e.getActionType())
+ .entityType(e.getEntityType())
+ .entityId(e.getEntityId())
+ .description(e.getDescription())
+ .ipAddress(e.getIpAddress())
+ .userAgent(e.getUserAgent())
+ .requestId(e.getRequestId())
+ .payloadAvant(e.getPayloadAvant())
+ .payloadApres(e.getPayloadApres())
+ .metadata(e.getMetadata())
+ .sodCheckPassed(e.getSodCheckPassed())
+ .sodViolations(e.getSodViolations())
+ .operationAt(e.getOperationAt())
+ .build();
+ }
+}
diff --git a/src/main/java/dev/lions/unionflow/server/service/delegation/RoleDelegationService.java b/src/main/java/dev/lions/unionflow/server/service/delegation/RoleDelegationService.java
index 9252f21..798c6aa 100644
--- a/src/main/java/dev/lions/unionflow/server/service/delegation/RoleDelegationService.java
+++ b/src/main/java/dev/lions/unionflow/server/service/delegation/RoleDelegationService.java
@@ -1,5 +1,7 @@
package dev.lions.unionflow.server.service.delegation;
+import dev.lions.unionflow.server.api.dto.delegation.request.CreateRoleDelegationRequest;
+import dev.lions.unionflow.server.api.dto.delegation.response.RoleDelegationResponse;
import dev.lions.unionflow.server.entity.RoleDelegation;
import dev.lions.unionflow.server.repository.RoleDelegationRepository;
import dev.lions.unionflow.server.security.AuditTrailService;
@@ -120,4 +122,59 @@ public class RoleDelegationService {
}
return expired.size();
}
+
+ // ────────────────────────────────────────────────────────────
+ // API publique orientée DTO (Sprint 10)
+ // ────────────────────────────────────────────────────────────
+
+ /** Crée une délégation à partir du DTO + rôles connus du délégataire. */
+ @Transactional
+ public RoleDelegationResponse creerDepuisRequest(
+ CreateRoleDelegationRequest req, Set rolesDelegataire) {
+ RoleDelegation entity = RoleDelegation.builder()
+ .organisationId(req.organisationId())
+ .delegantUserId(req.delegantUserId())
+ .delegataireUserId(req.delegataireUserId())
+ .roleDelegue(req.roleDelegue())
+ .dateDebut(req.dateDebut())
+ .dateFin(req.dateFin())
+ .motif(req.motif())
+ .statut("ACTIVE")
+ .actif(true)
+ .build();
+ return toResponse(creer(entity, rolesDelegataire));
+ }
+
+ /** Révoque et retourne le DTO. */
+ @Transactional
+ public RoleDelegationResponse revoquerEtRetourner(UUID delegationId) {
+ return toResponse(revoquer(delegationId));
+ }
+
+ /** Liste les délégations d'une organisation. */
+ public List listerParOrganisation(UUID organisationId) {
+ return repository.findByOrganisation(organisationId).stream()
+ .map(this::toResponse).collect(Collectors.toList());
+ }
+
+ // ────────────────────────────────────────────────────────────
+ // Mapping Entity ↔ DTO (centralisé ici par contrat architecture)
+ // ────────────────────────────────────────────────────────────
+
+ RoleDelegationResponse toResponse(RoleDelegation d) {
+ return RoleDelegationResponse.builder()
+ .id(d.getId())
+ .organisationId(d.getOrganisationId())
+ .delegantUserId(d.getDelegantUserId())
+ .delegataireUserId(d.getDelegataireUserId())
+ .roleDelegue(d.getRoleDelegue())
+ .dateDebut(d.getDateDebut())
+ .dateFin(d.getDateFin())
+ .motif(d.getMotif())
+ .statut(d.getStatut())
+ .dateRevocation(d.getDateRevocation())
+ .dateCreation(d.getDateCreation())
+ .estActive(d.isActiveAt(LocalDateTime.now()))
+ .build();
+ }
}
diff --git a/src/main/java/dev/lions/unionflow/server/service/kyc/BeneficiaireEffectifService.java b/src/main/java/dev/lions/unionflow/server/service/kyc/BeneficiaireEffectifService.java
new file mode 100644
index 0000000..713705b
--- /dev/null
+++ b/src/main/java/dev/lions/unionflow/server/service/kyc/BeneficiaireEffectifService.java
@@ -0,0 +1,219 @@
+package dev.lions.unionflow.server.service.kyc;
+
+import dev.lions.unionflow.server.api.dto.kyc.request.CreateBeneficiaireEffectifRequest;
+import dev.lions.unionflow.server.api.dto.kyc.request.UpdateBeneficiaireEffectifRequest;
+import dev.lions.unionflow.server.api.dto.kyc.response.BeneficiaireEffectifResponse;
+import dev.lions.unionflow.server.entity.BeneficiaireEffectif;
+import dev.lions.unionflow.server.entity.KycDossier;
+import dev.lions.unionflow.server.repository.BeneficiaireEffectifRepository;
+import dev.lions.unionflow.server.repository.KycDossierRepository;
+import dev.lions.unionflow.server.security.AuditTrailService;
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.inject.Inject;
+import jakarta.transaction.Transactional;
+import jakarta.ws.rs.NotFoundException;
+import java.math.BigDecimal;
+import java.util.List;
+import java.util.Objects;
+import java.util.UUID;
+import org.jboss.logging.Logger;
+
+/**
+ * Service métier des Bénéficiaires Effectifs (UBO).
+ *
+ * Couche unique pour business logic UBO. Valide les règles GAFI/BCEAO :
+ *
+ * - Somme des pourcentages capital ne dépasse pas 100 %
+ * - Au moins un identifiant cible (kycDossierId, organisationCibleId, ou membreId)
+ * - Cohérence PEP (si {@code estPep=true}, alerte audit trail)
+ *
+ *
+ * Les vérifications de rôles sont déléguées à la Resource via {@code @RolesAllowed}.
+ *
+ * @since 2026-04-25 (Sprint 10)
+ */
+@ApplicationScoped
+public class BeneficiaireEffectifService {
+
+ private static final Logger LOG = Logger.getLogger(BeneficiaireEffectifService.class);
+ private static final BigDecimal CENT = new BigDecimal("100.00");
+
+ @Inject BeneficiaireEffectifRepository repository;
+ @Inject KycDossierRepository kycDossierRepository;
+ @Inject AuditTrailService auditTrail;
+
+ // ────────────────────────────────────────────────────────────
+ // Lecture
+ // ────────────────────────────────────────────────────────────
+
+ public List listerParKycDossier(UUID kycDossierId) {
+ return repository.findByKycDossier(kycDossierId).stream().map(this::toResponse).toList();
+ }
+
+ public List listerParOrganisationCible(UUID organisationCibleId) {
+ return repository.findByOrganisationCible(organisationCibleId).stream().map(this::toResponse)
+ .toList();
+ }
+
+ public List listerPep() {
+ return repository.findPep().stream().map(this::toResponse).toList();
+ }
+
+ public BeneficiaireEffectifResponse trouverParId(UUID id) {
+ BeneficiaireEffectif e = repository.findById(id);
+ if (e == null) throw new NotFoundException("UBO introuvable : " + id);
+ return toResponse(e);
+ }
+
+ // ────────────────────────────────────────────────────────────
+ // Écriture
+ // ────────────────────────────────────────────────────────────
+
+ @Transactional
+ public BeneficiaireEffectifResponse creer(CreateBeneficiaireEffectifRequest req) {
+ if (req.kycDossierId() == null && req.organisationCibleId() == null && req.membreId() == null) {
+ throw new IllegalArgumentException(
+ "Au moins un identifiant cible obligatoire : kycDossierId, organisationCibleId ou membreId");
+ }
+
+ KycDossier kyc = null;
+ if (req.kycDossierId() != null) {
+ kyc = kycDossierRepository.findById(req.kycDossierId());
+ if (kyc == null) {
+ throw new NotFoundException("KycDossier introuvable : " + req.kycDossierId());
+ }
+ verifierSeuilPourcentage(req.kycDossierId(), null, req.pourcentageCapital());
+ }
+
+ BeneficiaireEffectif e = BeneficiaireEffectif.builder()
+ .kycDossier(kyc)
+ .organisationCibleId(req.organisationCibleId())
+ .membreId(req.membreId())
+ .nom(req.nom())
+ .prenoms(req.prenoms())
+ .dateNaissance(req.dateNaissance())
+ .lieuNaissance(req.lieuNaissance())
+ .nationalite(req.nationalite())
+ .paysResidence(req.paysResidence())
+ .typePieceIdentite(req.typePieceIdentite())
+ .numeroPieceIdentite(req.numeroPieceIdentite())
+ .dateExpirationPiece(req.dateExpirationPiece())
+ .pourcentageCapital(req.pourcentageCapital())
+ .pourcentageDroitsVote(req.pourcentageDroitsVote())
+ .natureControle(req.natureControle())
+ .estPep(Boolean.TRUE.equals(req.estPep()))
+ .pepCategorie(req.pepCategorie())
+ .pepPays(req.pepPays())
+ .pepFonction(req.pepFonction())
+ .presenceListesSanctions(Boolean.TRUE.equals(req.presenceListesSanctions()))
+ .detailsListesSanctions(req.detailsListesSanctions())
+ .actif(true)
+ .build();
+
+ repository.persist(e);
+
+ auditTrail.logSimple("BeneficiaireEffectif", e.getId(), "CREATE",
+ "UBO créé : " + req.prenoms() + " " + req.nom()
+ + (Boolean.TRUE.equals(req.estPep()) ? " [PEP]" : "")
+ + (Boolean.TRUE.equals(req.presenceListesSanctions()) ? " [SANCTIONS]" : ""));
+ LOG.infof("UBO créé id=%s pour kyc=%s", e.getId(), req.kycDossierId());
+ return toResponse(e);
+ }
+
+ @Transactional
+ public BeneficiaireEffectifResponse mettreAJour(UUID id, UpdateBeneficiaireEffectifRequest req) {
+ BeneficiaireEffectif e = repository.findById(id);
+ if (e == null) throw new NotFoundException("UBO introuvable : " + id);
+
+ if (req.pourcentageCapital() != null
+ && !Objects.equals(req.pourcentageCapital(), e.getPourcentageCapital())
+ && e.getKycDossier() != null) {
+ verifierSeuilPourcentage(e.getKycDossier().getId(), id, req.pourcentageCapital());
+ }
+
+ if (req.nom() != null) e.setNom(req.nom());
+ if (req.prenoms() != null) e.setPrenoms(req.prenoms());
+ if (req.dateNaissance() != null) e.setDateNaissance(req.dateNaissance());
+ if (req.lieuNaissance() != null) e.setLieuNaissance(req.lieuNaissance());
+ if (req.nationalite() != null && !req.nationalite().isBlank()) e.setNationalite(req.nationalite());
+ if (req.paysResidence() != null) e.setPaysResidence(req.paysResidence().isBlank() ? null : req.paysResidence());
+ if (req.pourcentageCapital() != null) e.setPourcentageCapital(req.pourcentageCapital());
+ if (req.pourcentageDroitsVote() != null) e.setPourcentageDroitsVote(req.pourcentageDroitsVote());
+ if (req.natureControle() != null && !req.natureControle().isBlank()) e.setNatureControle(req.natureControle());
+ if (req.estPep() != null) e.setEstPep(req.estPep());
+ if (req.pepCategorie() != null) e.setPepCategorie(req.pepCategorie());
+ if (req.pepPays() != null) e.setPepPays(req.pepPays().isBlank() ? null : req.pepPays());
+ if (req.pepFonction() != null) e.setPepFonction(req.pepFonction());
+ if (req.presenceListesSanctions() != null) e.setPresenceListesSanctions(req.presenceListesSanctions());
+ if (req.detailsListesSanctions() != null) e.setDetailsListesSanctions(req.detailsListesSanctions());
+
+ auditTrail.logSimple("BeneficiaireEffectif", id, "UPDATE", "Mise à jour UBO");
+ return toResponse(e);
+ }
+
+ @Transactional
+ public void desactiver(UUID id) {
+ BeneficiaireEffectif e = repository.findById(id);
+ if (e == null) throw new NotFoundException("UBO introuvable : " + id);
+ e.setActif(false);
+ auditTrail.logSimple("BeneficiaireEffectif", id, "DELETE", "UBO désactivé (soft-delete)");
+ }
+
+ // ────────────────────────────────────────────────────────────
+ // Validations
+ // ────────────────────────────────────────────────────────────
+
+ /**
+ * Vérifie que la somme des pourcentages capital sur un KycDossier ne dépasse pas 100 %.
+ * Exclut l'UBO en cours de modification ({@code excludeId} = id existant à mettre à jour).
+ */
+ void verifierSeuilPourcentage(UUID kycDossierId, UUID excludeId, BigDecimal nouveauPourcentage) {
+ if (nouveauPourcentage == null) return;
+ BigDecimal cumul = repository.findByKycDossier(kycDossierId).stream()
+ .filter(b -> Boolean.TRUE.equals(b.getActif()))
+ .filter(b -> excludeId == null || !excludeId.equals(b.getId()))
+ .map(BeneficiaireEffectif::getPourcentageCapital)
+ .filter(Objects::nonNull)
+ .reduce(BigDecimal.ZERO, BigDecimal::add);
+ BigDecimal total = cumul.add(nouveauPourcentage);
+ if (total.compareTo(CENT) > 0) {
+ throw new IllegalArgumentException(
+ "Somme des pourcentages capital UBO dépasserait 100 % : " + total
+ + " (cumul existant : " + cumul + ", nouveau : " + nouveauPourcentage + ")");
+ }
+ }
+
+ // ────────────────────────────────────────────────────────────
+ // Mapping Entity ↔ DTO
+ // ────────────────────────────────────────────────────────────
+
+ BeneficiaireEffectifResponse toResponse(BeneficiaireEffectif e) {
+ return BeneficiaireEffectifResponse.builder()
+ .id(e.getId())
+ .kycDossierId(e.getKycDossier() != null ? e.getKycDossier().getId() : null)
+ .organisationCibleId(e.getOrganisationCibleId())
+ .membreId(e.getMembreId())
+ .nom(e.getNom())
+ .prenoms(e.getPrenoms())
+ .dateNaissance(e.getDateNaissance())
+ .lieuNaissance(e.getLieuNaissance())
+ .nationalite(e.getNationalite())
+ .paysResidence(e.getPaysResidence())
+ .typePieceIdentite(e.getTypePieceIdentite())
+ .numeroPieceIdentite(e.getNumeroPieceIdentite())
+ .dateExpirationPiece(e.getDateExpirationPiece())
+ .pourcentageCapital(e.getPourcentageCapital())
+ .pourcentageDroitsVote(e.getPourcentageDroitsVote())
+ .natureControle(e.getNatureControle())
+ .estPep(e.isEstPep())
+ .pepCategorie(e.getPepCategorie())
+ .pepPays(e.getPepPays())
+ .pepFonction(e.getPepFonction())
+ .presenceListesSanctions(e.isPresenceListesSanctions())
+ .detailsListesSanctions(e.getDetailsListesSanctions())
+ .dateCreation(e.getDateCreation())
+ .dateModification(e.getDateModification())
+ .actif(e.getActif())
+ .build();
+ }
+}
diff --git a/src/test/java/dev/lions/unionflow/server/service/audit/AuditTrailQueryServiceTest.java b/src/test/java/dev/lions/unionflow/server/service/audit/AuditTrailQueryServiceTest.java
new file mode 100644
index 0000000..1eec9e5
--- /dev/null
+++ b/src/test/java/dev/lions/unionflow/server/service/audit/AuditTrailQueryServiceTest.java
@@ -0,0 +1,100 @@
+package dev.lions.unionflow.server.service.audit;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.when;
+
+import dev.lions.unionflow.server.entity.AuditTrailOperation;
+import dev.lions.unionflow.server.repository.AuditTrailOperationRepository;
+import java.lang.reflect.Field;
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.UUID;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+@ExtendWith(MockitoExtension.class)
+class AuditTrailQueryServiceTest {
+
+ @Mock AuditTrailOperationRepository repository;
+
+ private AuditTrailQueryService service;
+
+ @BeforeEach
+ void setUp() throws Exception {
+ service = new AuditTrailQueryService();
+ Field f = AuditTrailQueryService.class.getDeclaredField("repository");
+ f.setAccessible(true);
+ f.set(service, repository);
+ }
+
+ private AuditTrailOperation op(String entityType, String actionType, Boolean sodOk) {
+ AuditTrailOperation o = new AuditTrailOperation();
+ o.setId(UUID.randomUUID());
+ o.setUserId(UUID.randomUUID());
+ o.setEntityType(entityType);
+ o.setActionType(actionType);
+ o.setSodCheckPassed(sodOk);
+ o.setOperationAt(LocalDateTime.now());
+ return o;
+ }
+
+ @Test
+ @DisplayName("rechercherParUtilisateur appelle le repo + mappe en DTO")
+ void parUtilisateur() {
+ UUID userId = UUID.randomUUID();
+ LocalDateTime from = LocalDateTime.now().minusDays(1);
+ LocalDateTime to = LocalDateTime.now();
+ when(repository.findByUserBetween(userId, from, to))
+ .thenReturn(List.of(op("Membre", "UPDATE", true), op("Cotisation", "CREATE", true)));
+
+ var result = service.rechercherParUtilisateur(userId, from, to);
+ assertThat(result).hasSize(2);
+ assertThat(result).extracting("actionType").containsExactly("UPDATE", "CREATE");
+ }
+
+ @Test
+ @DisplayName("violationsSod retourne la liste des violations")
+ void sodViolations() {
+ when(repository.findSoDViolations())
+ .thenReturn(List.of(op("BudgetApproval", "VALIDATE", false)));
+ var result = service.violationsSod();
+ assertThat(result).hasSize(1);
+ assertThat(result.get(0).sodCheckPassed()).isFalse();
+ }
+
+ @Test
+ @DisplayName("toResponse mappe tous les champs")
+ void toResponseMapping() {
+ UUID id = UUID.randomUUID();
+ UUID userId = UUID.randomUUID();
+ UUID orgId = UUID.randomUUID();
+
+ AuditTrailOperation e = AuditTrailOperation.builder()
+ .id(id).userId(userId).userEmail("a@b.com").roleActif("TRESORIER")
+ .organisationActiveId(orgId).actionType("PAYMENT_INITIATED").entityType("Paiement")
+ .description("test").sodCheckPassed(true)
+ .operationAt(LocalDateTime.of(2026, 4, 25, 12, 0))
+ .build();
+ var r = service.toResponse(e);
+ assertThat(r.id()).isEqualTo(id);
+ assertThat(r.userEmail()).isEqualTo("a@b.com");
+ assertThat(r.actionType()).isEqualTo("PAYMENT_INITIATED");
+ assertThat(r.organisationActiveId()).isEqualTo(orgId);
+ assertThat(r.sodCheckPassed()).isTrue();
+ }
+
+ @Test
+ @DisplayName("historiqueEntite filtre par type et id")
+ void historique() {
+ UUID id = UUID.randomUUID();
+ when(repository.findByEntity("Membre", id))
+ .thenReturn(List.of(op("Membre", "UPDATE", true)));
+ var result = service.historiqueEntite("Membre", id);
+ assertThat(result).hasSize(1);
+ assertThat(result.get(0).entityType()).isEqualTo("Membre");
+ }
+}
diff --git a/src/test/java/dev/lions/unionflow/server/service/kyc/BeneficiaireEffectifServiceTest.java b/src/test/java/dev/lions/unionflow/server/service/kyc/BeneficiaireEffectifServiceTest.java
new file mode 100644
index 0000000..2f090bf
--- /dev/null
+++ b/src/test/java/dev/lions/unionflow/server/service/kyc/BeneficiaireEffectifServiceTest.java
@@ -0,0 +1,167 @@
+package dev.lions.unionflow.server.service.kyc;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.Mockito.when;
+
+import dev.lions.unionflow.server.api.dto.kyc.request.CreateBeneficiaireEffectifRequest;
+import dev.lions.unionflow.server.entity.BeneficiaireEffectif;
+import dev.lions.unionflow.server.entity.KycDossier;
+import dev.lions.unionflow.server.repository.BeneficiaireEffectifRepository;
+import dev.lions.unionflow.server.repository.KycDossierRepository;
+import dev.lions.unionflow.server.security.AuditTrailService;
+import java.lang.reflect.Field;
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.util.List;
+import java.util.UUID;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+@ExtendWith(MockitoExtension.class)
+class BeneficiaireEffectifServiceTest {
+
+ @Mock BeneficiaireEffectifRepository repository;
+ @Mock KycDossierRepository kycDossierRepository;
+ @Mock AuditTrailService auditTrailService;
+
+ private BeneficiaireEffectifService service;
+
+ @BeforeEach
+ void setUp() throws Exception {
+ service = new BeneficiaireEffectifService();
+ inject(service, "repository", repository);
+ inject(service, "kycDossierRepository", kycDossierRepository);
+ inject(service, "auditTrail", auditTrailService);
+ }
+
+ private void inject(Object t, String name, Object value) throws Exception {
+ Field f = t.getClass().getDeclaredField(name);
+ f.setAccessible(true);
+ f.set(t, value);
+ }
+
+ private BeneficiaireEffectif ubo(BigDecimal pct, UUID kycId) {
+ BeneficiaireEffectif b = new BeneficiaireEffectif();
+ b.setId(UUID.randomUUID());
+ b.setPourcentageCapital(pct);
+ b.setActif(true);
+ if (kycId != null) {
+ KycDossier k = new KycDossier();
+ k.setId(kycId);
+ b.setKycDossier(k);
+ }
+ return b;
+ }
+
+ // ── verifierSeuilPourcentage ──────────────────────────────────────────────
+
+ @Test
+ @DisplayName("Pourcentage cumulé < 100 → OK")
+ void seuil_OK() {
+ UUID kyc = UUID.randomUUID();
+ when(repository.findByKycDossier(kyc))
+ .thenReturn(List.of(ubo(new BigDecimal("30"), kyc), ubo(new BigDecimal("40"), kyc)));
+ service.verifierSeuilPourcentage(kyc, null, new BigDecimal("25"));
+ // pas d'exception
+ }
+
+ @Test
+ @DisplayName("Pourcentage cumulé == 100 → OK (limite incluse)")
+ void seuil_LimiteOK() {
+ UUID kyc = UUID.randomUUID();
+ when(repository.findByKycDossier(kyc))
+ .thenReturn(List.of(ubo(new BigDecimal("60"), kyc)));
+ service.verifierSeuilPourcentage(kyc, null, new BigDecimal("40"));
+ }
+
+ @Test
+ @DisplayName("Pourcentage cumulé > 100 → exception")
+ void seuil_Depasse() {
+ UUID kyc = UUID.randomUUID();
+ when(repository.findByKycDossier(kyc))
+ .thenReturn(List.of(ubo(new BigDecimal("60"), kyc), ubo(new BigDecimal("30"), kyc)));
+ assertThatThrownBy(() ->
+ service.verifierSeuilPourcentage(kyc, null, new BigDecimal("20")))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("dépasserait 100");
+ }
+
+ @Test
+ @DisplayName("excludeId ignore l'UBO en cours de modification")
+ void seuil_ExcludeId() {
+ UUID kyc = UUID.randomUUID();
+ UUID excludeId = UUID.randomUUID();
+ BeneficiaireEffectif existant = ubo(new BigDecimal("60"), kyc);
+ existant.setId(excludeId);
+ when(repository.findByKycDossier(kyc))
+ .thenReturn(List.of(existant, ubo(new BigDecimal("30"), kyc)));
+ // 80 nouveau, excludeId enlève les 60 existants → cumul = 30 + 80 = 110 → fail
+ assertThatThrownBy(() ->
+ service.verifierSeuilPourcentage(kyc, excludeId, new BigDecimal("80")))
+ .isInstanceOf(IllegalArgumentException.class);
+ // mais 50 → cumul = 30 + 50 = 80 → OK
+ service.verifierSeuilPourcentage(kyc, excludeId, new BigDecimal("50"));
+ }
+
+ @Test
+ @DisplayName("Pourcentage null → no-op")
+ void seuil_PourcentageNull() {
+ service.verifierSeuilPourcentage(UUID.randomUUID(), null, null);
+ }
+
+ @Test
+ @DisplayName("UBOs inactifs ignorés du cumul")
+ void seuil_IgnoreInactifs() {
+ UUID kyc = UUID.randomUUID();
+ BeneficiaireEffectif inactif = ubo(new BigDecimal("80"), kyc);
+ inactif.setActif(false);
+ when(repository.findByKycDossier(kyc))
+ .thenReturn(List.of(inactif));
+ // 80 inactif ignoré → cumul = 30 → OK
+ service.verifierSeuilPourcentage(kyc, null, new BigDecimal("30"));
+ }
+
+ // ── creer : validation cible ─────────────────────────────────────────────
+
+ @Test
+ @DisplayName("creer sans aucune cible → exception")
+ void creer_AucuneCible() {
+ CreateBeneficiaireEffectifRequest req = CreateBeneficiaireEffectifRequest.builder()
+ .nom("Doe").prenoms("John")
+ .dateNaissance(LocalDate.of(1980, 1, 1))
+ .nationalite("CIV")
+ .natureControle("DETENTION_CAPITAL")
+ .build();
+ assertThatThrownBy(() -> service.creer(req))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("identifiant cible");
+ }
+
+ // ── toResponse mapping ───────────────────────────────────────────────────
+
+ @Test
+ @DisplayName("toResponse mappe correctement les champs principaux")
+ void toResponse_Mapping() {
+ UUID id = UUID.randomUUID();
+ BeneficiaireEffectif e = new BeneficiaireEffectif();
+ e.setId(id);
+ e.setNom("Dupont");
+ e.setPrenoms("Jean");
+ e.setNationalite("CIV");
+ e.setEstPep(true);
+ e.setNatureControle("DETENTION_CAPITAL");
+ e.setActif(true);
+ var r = service.toResponse(e);
+ assertThat(r.id()).isEqualTo(id);
+ assertThat(r.nom()).isEqualTo("Dupont");
+ assertThat(r.prenoms()).isEqualTo("Jean");
+ assertThat(r.estPep()).isTrue();
+ assertThat(r.natureControle()).isEqualTo("DETENTION_CAPITAL");
+ }
+
+}