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
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:
2
pom.xml
2
pom.xml
@@ -61,7 +61,7 @@
|
||||
<dependency>
|
||||
<groupId>dev.lions.unionflow</groupId>
|
||||
<artifactId>unionflow-server-api</artifactId>
|
||||
<version>1.0.7</version>
|
||||
<version>1.0.8</version>
|
||||
</dependency>
|
||||
|
||||
<!-- Lions User Manager API (pour DTOs et client Keycloak) -->
|
||||
|
||||
@@ -24,4 +24,9 @@ public class RoleDelegationRepository implements PanacheRepositoryBase<RoleDeleg
|
||||
public List<RoleDelegation> findExpired(LocalDateTime 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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(","));
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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<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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user