diff --git a/pom.xml b/pom.xml index 6e5a1b6..30f1b54 100644 --- a/pom.xml +++ b/pom.xml @@ -61,7 +61,7 @@ dev.lions.unionflow unionflow-server-api - 1.0.7 + 1.0.8 diff --git a/src/main/java/dev/lions/unionflow/server/repository/RoleDelegationRepository.java b/src/main/java/dev/lions/unionflow/server/repository/RoleDelegationRepository.java index e085864..84687dc 100644 --- a/src/main/java/dev/lions/unionflow/server/repository/RoleDelegationRepository.java +++ b/src/main/java/dev/lions/unionflow/server/repository/RoleDelegationRepository.java @@ -24,4 +24,9 @@ public class RoleDelegationRepository implements PanacheRepositoryBase findExpired(LocalDateTime now) { return list("statut = 'ACTIVE' AND dateFin <= ?1", now); } + + /** Liste paginable / filtrable par organisation pour vue admin (Sprint 10). */ + public List 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 : + *

+ * + *

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