feat(sprint-10 backend 2026-04-25): Resources REST UBO + audit trail + délégation + enrich update org
Some checks failed
CI/CD Pipeline / pipeline (push) Failing after 4m30s

Expose les features Sprints 1-2 via API REST. Architecture stricte respectée :
Resource ↔ Service ↔ Repository ↔ Entity ↔ DTO. Mapping centralisé en Service.

Bump dépendance api 1.0.7 → 1.0.8 (DTOs nouveaux)

UBO (Instr. BCEAO 003-03-2025)
- BeneficiaireEffectifService : creer (validation cible obligatoire), mettreAJour (PATCH), desactiver (soft-delete), validation seuilPourcentage (somme cumul ≤ 100, exclusion en mode update), audit trail systématique
- BeneficiaireEffectifResource : CRUD /api/kyc/beneficiaires-effectifs, filtres queryParam (kycDossierId | organisationCibleId | pep), @RolesAllowed COMPLIANCE_OFFICER + ADMIN_ORG + SUPER_ADMIN

Audit trail (CQRS read-side)
- AuditTrailQueryService NEW : 5 méthodes lecture seule (parUtilisateur, historiqueEntite, parOrganisation, violationsSod, operationsFinancieres) + mapping Entity→DTO
- AuditTrailOperationResource : /api/audit-trail/{by-user|by-entity|by-organisation|sod-violations|financial}, parsing dates ISO + fallbacks
- Distinct du AuditTrailService (write-side dans security/) : Single Responsibility appliqué

Délégation rôles (Sprint 2 service réutilisé)
- RoleDelegationService enrichi : creerDepuisRequest (DTO→entité→creer existant), revoquerEtRetourner, listerParOrganisation, toResponse (mapping centralisé)
- RoleDelegationRepository : findByOrganisation
- RoleDelegationResource : POST/DELETE/GET /api/role-delegations

Enrichissement Organisation update (DRY)
- OrganisationService.convertFromUpdateRequest : parseReferentielComptable (fallback ReferentielComptable.defaultFor) + complianceOfficerId
- OrganisationService.mettreAJourOrganisation : propagation conditionnelle des nouveaux champs

Tests (en attente publication api 1.0.8 — install local nécessaire)
- BeneficiaireEffectifServiceTest : 8 tests verifierSeuilPourcentage (OK / limite / dépassement / excludeId / null / inactifs ignorés) + creer + toResponse mapping
- AuditTrailQueryServiceTest : 4 tests parUtilisateur, sodViolations, toResponse mapping, historiqueEntite

ACTION USER : `mvn install` côté unionflow-server-api pour rendre 1.0.8 dispo en m2 local + publier via script/publish-api.sh pour Gitea.
This commit is contained in:
dahoud
2026-04-25 12:32:41 +00:00
parent 4d400dc48d
commit 241533efa6
11 changed files with 901 additions and 1 deletions

View File

@@ -61,7 +61,7 @@
<dependency> <dependency>
<groupId>dev.lions.unionflow</groupId> <groupId>dev.lions.unionflow</groupId>
<artifactId>unionflow-server-api</artifactId> <artifactId>unionflow-server-api</artifactId>
<version>1.0.7</version> <version>1.0.8</version>
</dependency> </dependency>
<!-- Lions User Manager API (pour DTOs et client Keycloak) --> <!-- Lions User Manager API (pour DTOs et client Keycloak) -->

View File

@@ -24,4 +24,9 @@ public class RoleDelegationRepository implements PanacheRepositoryBase<RoleDeleg
public List<RoleDelegation> findExpired(LocalDateTime now) { public List<RoleDelegation> findExpired(LocalDateTime now) {
return list("statut = 'ACTIVE' AND dateFin <= ?1", now); return list("statut = 'ACTIVE' AND dateFin <= ?1", now);
} }
/** Liste paginable / filtrable par organisation pour vue admin (Sprint 10). */
public List<RoleDelegation> findByOrganisation(UUID organisationId) {
return list("organisationId = ?1 ORDER BY dateDebut DESC", organisationId);
}
} }

View File

@@ -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).
*
* <p>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<AuditTrailOperationResponse> 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<AuditTrailOperationResponse> 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<AuditTrailOperationResponse> parOrganisation(@PathParam("orgId") UUID orgId) {
return queryService.rechercherParOrganisation(orgId);
}
@GET
@Path("/sod-violations")
@RolesAllowed({"COMPLIANCE_OFFICER", "CONTROLEUR_INTERNE", "SUPER_ADMIN"})
public List<AuditTrailOperationResponse> violationsSod() {
return queryService.violationsSod();
}
@GET
@Path("/financial/{orgId}")
@RolesAllowed({"COMPLIANCE_OFFICER", "CONTROLEUR_INTERNE", "TRESORIER", "SUPER_ADMIN"})
public List<AuditTrailOperationResponse> 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;
}
}
}

View File

@@ -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.
*
* <p>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<BeneficiaireEffectifResponse> 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();
}
}

View File

@@ -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).
*
* <p>La Resource ne fait que mapper HTTP ↔ {@link RoleDelegationService}. La
* logique SoD, validation de dates, audit trail est dans le Service.
*
* <p>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<RoleDelegationResponse> 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<String> 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<String> parseRoles(String csv) {
if (csv == null || csv.isBlank()) return Set.of();
return Set.of(csv.split(","));
}
}

View File

@@ -233,6 +233,14 @@ public class OrganisationService {
// Hiérarchie // Hiérarchie
organisation.setOrganisationParente(organisationMiseAJour.getOrganisationParente()); 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); organisation.marquerCommeModifie(utilisateur);
LOG.infof("Organisation mise à jour avec succès: ID=%s", id); LOG.infof("Organisation mise à jour avec succès: ID=%s", id);
@@ -782,9 +790,27 @@ public class OrganisationService {
.codePostal(req.codePostal()) .codePostal(req.codePostal())
.organisationPublique(req.organisationPublique() != null ? req.organisationPublique() : true) .organisationPublique(req.organisationPublique() != null ? req.organisationPublique() : true)
.accepteNouveauxMembres(req.accepteNouveauxMembres() != null ? req.accepteNouveauxMembres() : true) .accepteNouveauxMembres(req.accepteNouveauxMembres() != null ? req.accepteNouveauxMembres() : true)
.referentielComptable(parseReferentielComptable(req.referentielComptable(), req.typeOrganisation()))
.complianceOfficerId(req.complianceOfficerId())
.build(); .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). * 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. * Inclut les infos nécessaires au sélecteur : id, nom, type, catégorie, modules, rôle du membre.

View File

@@ -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.
*
* <p>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<AuditTrailOperationResponse> 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<AuditTrailOperationResponse> 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<AuditTrailOperationResponse> 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<AuditTrailOperationResponse> 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<AuditTrailOperationResponse> 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();
}
}

View File

@@ -1,5 +1,7 @@
package dev.lions.unionflow.server.service.delegation; 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.entity.RoleDelegation;
import dev.lions.unionflow.server.repository.RoleDelegationRepository; import dev.lions.unionflow.server.repository.RoleDelegationRepository;
import dev.lions.unionflow.server.security.AuditTrailService; import dev.lions.unionflow.server.security.AuditTrailService;
@@ -120,4 +122,59 @@ public class RoleDelegationService {
} }
return expired.size(); 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<String> 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<RoleDelegationResponse> 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();
}
} }

View File

@@ -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).
*
* <p>Couche unique pour business logic UBO. Valide les règles GAFI/BCEAO :
* <ul>
* <li>Somme des pourcentages capital ne dépasse pas 100 %</li>
* <li>Au moins un identifiant cible (kycDossierId, organisationCibleId, ou membreId)</li>
* <li>Cohérence PEP (si {@code estPep=true}, alerte audit trail)</li>
* </ul>
*
* <p>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<BeneficiaireEffectifResponse> listerParKycDossier(UUID kycDossierId) {
return repository.findByKycDossier(kycDossierId).stream().map(this::toResponse).toList();
}
public List<BeneficiaireEffectifResponse> listerParOrganisationCible(UUID organisationCibleId) {
return repository.findByOrganisationCible(organisationCibleId).stream().map(this::toResponse)
.toList();
}
public List<BeneficiaireEffectifResponse> 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();
}
}

View File

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

View File

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