feat(sprint-3 P1+P2 2026-04-25): compliance dashboard + PEP screening + formations LBC/FT + goAML XML + tests
Some checks failed
CI/CD Pipeline / pipeline (push) Failing after 3m15s

P1-NEW-11 — Tableau de bord conformité
- ComplianceDashboardService : score 0-100 avec 9 indicateurs pondérés (compliance officer, AG annuelle, rapport AIRMS, dirigeants CMU, taux KYC, taux formation LBC/FT, CAC, FOMUS-CI, couverture UBO)
- ComplianceDashboardResource : GET /api/compliance/dashboard

P1-NEW-11 — PEP screening externe
- PepScreeningProvider (interface) + records PepScreeningResult / PepMatch
- InMemoryPepScreeningProvider : fallback dev avec similarité Levenshtein normalisée (seuil 0.80)
- PepScreeningService : cache LRU 5000 entrées, TTL 24h
- Pluggable via @Alternative @Priority pour Youverify / ComplyAdvantage en prod

P1-NEW-12 — Module formation LBC/FT obligatoire annuelle
- FormationLbcFt + ParticipationFormationLbcFt (entités)
- FormationLbcFtService : creer, inscrire, marquerPresent, certifier (score >= 70), estCertifieAnneeCourante
- Repositories : trouverCertificationAnnee, findATenir
- V47 migration : formations_lbcft, participations_formation_lbcft, pep_screening_cache

P2-NEW-4 — goAML XML (anticipation adoption CI)
- GoAmlXmlService : génération XML standard ONUDC (report, transaction, t_person, t_account)
- Reporting person anonymisé ([REDACTED], rôle Compliance Officer)
- POST /api/aml/dos/goaml @RolesAllowed(COMPLIANCE_OFFICER, SUPER_ADMIN)

Tests Sprint 3 (15 tests, 100%) :
- FormationLbcFtTest : 2 tests (builder, override type)
- GoAmlXmlServiceTest : 4 tests (XML non vide, champs clés, anonymisation, country code CIV)
- InMemoryPepScreeningProviderTest : 9 tests (match, nationalité, vide, similarité Levenshtein)
This commit is contained in:
dahoud
2026-04-25 08:37:06 +00:00
parent c54092bd78
commit 8b589477ec
16 changed files with 1288 additions and 0 deletions

View File

@@ -0,0 +1,75 @@
package dev.lions.unionflow.server.entity;
import jakarta.persistence.*;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.UUID;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
/**
* Session de formation LBC/FT (lutte contre le blanchiment de capitaux et le financement du
* terrorisme).
*
* <p>Obligation annuelle posée par l'<strong>Instruction BCEAO 001-03-2025 du 18 mars 2025</strong>
* pour le compliance officer + les dirigeants + les membres exposés (trésorier, secrétaire,
* commissaires aux comptes).
*
* @since 2026-04-25 (P1-NEW-12)
*/
@Entity
@Table(name = "formations_lbcft", indexes = {
@Index(name = "idx_formation_org_annee", columnList = "organisation_id,annee_reference"),
@Index(name = "idx_formation_date", columnList = "date_session")
})
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@EqualsAndHashCode(callSuper = true)
public class FormationLbcFt extends BaseEntity {
@NotNull
@Column(name = "organisation_id", nullable = false)
private UUID organisationId;
@NotBlank
@Column(name = "titre", nullable = false, length = 255)
private String titre;
@NotBlank
@Column(name = "type_formation", nullable = false, length = 30)
@Builder.Default
private String typeFormation = "STANDARD"; // STANDARD, AVANCE, COMPLIANCE_OFFICER, DIRIGEANT
@Column(name = "contenu", columnDefinition = "TEXT")
private String contenu;
@Column(name = "intervenant", length = 255)
private String intervenant;
@Column(name = "duree_heures", precision = 4, scale = 1, nullable = false)
@Builder.Default
private BigDecimal dureeHeures = new BigDecimal("4.0");
@NotNull
@Column(name = "date_session", nullable = false)
private LocalDateTime dateSession;
@Column(name = "lieu", length = 255)
private String lieu;
@NotNull
@Column(name = "annee_reference", nullable = false)
private Integer anneeReference;
@NotBlank
@Column(name = "statut", nullable = false, length = 20)
@Builder.Default
private String statut = "PLANIFIEE"; // PLANIFIEE, EN_COURS, TERMINEE, ANNULEE
}

View File

@@ -0,0 +1,56 @@
package dev.lions.unionflow.server.entity;
import jakarta.persistence.*;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.UUID;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
/**
* Participation d'un membre à une session de formation LBC/FT.
*
* @since 2026-04-25 (P1-NEW-12)
*/
@Entity
@Table(name = "participations_formation_lbcft",
uniqueConstraints = @UniqueConstraint(columnNames = {"formation_id", "membre_id"}),
indexes = {
@Index(name = "idx_participation_membre", columnList = "membre_id"),
@Index(name = "idx_participation_statut", columnList = "statut_participation")
})
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@EqualsAndHashCode(callSuper = true)
public class ParticipationFormationLbcFt extends BaseEntity {
@NotNull
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "formation_id", nullable = false)
private FormationLbcFt formation;
@NotNull
@Column(name = "membre_id", nullable = false)
private UUID membreId;
@NotBlank
@Column(name = "statut_participation", nullable = false, length = 20)
@Builder.Default
private String statutParticipation = "INSCRIT"; // INSCRIT, PRESENT, ABSENT, CERTIFIE
@Column(name = "date_certification")
private LocalDateTime dateCertification;
@Column(name = "numero_certificat", length = 100)
private String numeroCertificat;
@Column(name = "score_quiz", precision = 5, scale = 2)
private BigDecimal scoreQuiz;
}

View File

@@ -0,0 +1,21 @@
package dev.lions.unionflow.server.repository;
import dev.lions.unionflow.server.entity.FormationLbcFt;
import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase;
import jakarta.enterprise.context.ApplicationScoped;
import java.util.List;
import java.util.UUID;
/** Repository des sessions de formation LBC/FT. */
@ApplicationScoped
public class FormationLbcFtRepository implements PanacheRepositoryBase<FormationLbcFt, UUID> {
public List<FormationLbcFt> findByOrganisationAndAnnee(UUID organisationId, int annee) {
return list("organisationId = ?1 AND anneeReference = ?2 ORDER BY dateSession DESC",
organisationId, annee);
}
public List<FormationLbcFt> findATenir(UUID organisationId) {
return list("organisationId = ?1 AND statut = 'PLANIFIEE' ORDER BY dateSession", organisationId);
}
}

View File

@@ -0,0 +1,29 @@
package dev.lions.unionflow.server.repository;
import dev.lions.unionflow.server.entity.ParticipationFormationLbcFt;
import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase;
import jakarta.enterprise.context.ApplicationScoped;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
/** Repository des participations aux formations LBC/FT. */
@ApplicationScoped
public class ParticipationFormationLbcFtRepository
implements PanacheRepositoryBase<ParticipationFormationLbcFt, UUID> {
public List<ParticipationFormationLbcFt> findByFormation(UUID formationId) {
return list("formation.id = ?1", formationId);
}
public List<ParticipationFormationLbcFt> findByMembre(UUID membreId) {
return list("membreId = ?1 ORDER BY dateCertification DESC", membreId);
}
/** Vérifie si un membre est certifié pour une année donnée. */
public Optional<ParticipationFormationLbcFt> trouverCertificationAnnee(UUID membreId, int annee) {
return find(
"membreId = ?1 AND formation.anneeReference = ?2 AND statutParticipation = 'CERTIFIE'",
membreId, annee).firstResultOptional();
}
}

View File

@@ -0,0 +1,42 @@
package dev.lions.unionflow.server.resource;
import dev.lions.unionflow.server.security.OrganisationContextHolder;
import dev.lions.unionflow.server.service.compliance.ComplianceDashboardService;
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.core.MediaType;
import java.util.UUID;
/** Endpoint du tableau de bord de conformité (P1-NEW-7). */
@Path("/api/compliance/dashboard")
@Authenticated
public class ComplianceDashboardResource {
@Inject ComplianceDashboardService service;
@Inject OrganisationContextHolder context;
@GET
@Produces(MediaType.APPLICATION_JSON)
@RolesAllowed({"PRESIDENT", "TRESORIER", "COMPLIANCE_OFFICER", "CONTROLEUR_INTERNE",
"ADMIN_ORGANISATION", "SUPER_ADMIN"})
public ComplianceDashboardService.ComplianceSnapshot snapshotCurrent() {
UUID orgId = context.getOrganisationId();
if (orgId == null) {
throw new IllegalStateException("Aucune organisation active dans le contexte");
}
return service.snapshot(orgId);
}
@GET
@Path("/{organisationId}")
@Produces(MediaType.APPLICATION_JSON)
@RolesAllowed({"SUPER_ADMIN"})
public ComplianceDashboardService.ComplianceSnapshot snapshotOf(@PathParam("organisationId") UUID orgId) {
return service.snapshot(orgId);
}
}

View File

@@ -2,6 +2,7 @@ package dev.lions.unionflow.server.resource;
import dev.lions.unionflow.server.service.centif.DosCentifService; import dev.lions.unionflow.server.service.centif.DosCentifService;
import dev.lions.unionflow.server.service.centif.DosCentifService.DosCentifData; import dev.lions.unionflow.server.service.centif.DosCentifService.DosCentifData;
import dev.lions.unionflow.server.service.centif.GoAmlXmlService;
import io.quarkus.security.Authenticated; import io.quarkus.security.Authenticated;
import jakarta.annotation.security.RolesAllowed; import jakarta.annotation.security.RolesAllowed;
import jakarta.inject.Inject; import jakarta.inject.Inject;
@@ -29,6 +30,7 @@ import java.io.IOException;
public class DosCentifResource { public class DosCentifResource {
@Inject DosCentifService dosService; @Inject DosCentifService dosService;
@Inject GoAmlXmlService goAmlService;
/** /**
* Génère la DOS au format Word (.docx). * Génère la DOS au format Word (.docx).
@@ -67,4 +69,21 @@ public class DosCentifResource {
.header("Content-Disposition", "attachment; filename=\"" + filename + "\"") .header("Content-Disposition", "attachment; filename=\"" + filename + "\"")
.build(); .build();
} }
/**
* Génère la DOS au format goAML XML (standard ONUDC) — anticipation adoption CI.
* @since P2-NEW-4
*/
@POST
@Path("/goaml")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_XML)
@RolesAllowed({"COMPLIANCE_OFFICER", "SUPER_ADMIN"})
public Response genererGoAml(DosCentifData data) throws IOException {
byte[] bytes = goAmlService.genererXml(data);
String filename = "DOS_goAML_" + data.numeroDosInterne() + ".xml";
return Response.ok(bytes)
.header("Content-Disposition", "attachment; filename=\"" + filename + "\"")
.build();
}
} }

View File

@@ -0,0 +1,123 @@
package dev.lions.unionflow.server.service.centif;
import dev.lions.unionflow.server.security.AuditTrailService;
import dev.lions.unionflow.server.service.centif.DosCentifService.DosCentifData;
import dev.lions.unionflow.server.service.centif.DosCentifService.DosOperation;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.time.format.DateTimeFormatter;
import javax.xml.stream.XMLOutputFactory;
import javax.xml.stream.XMLStreamException;
import javax.xml.stream.XMLStreamWriter;
/**
* Génération de DOS au format <strong>goAML XML</strong> (standard ONUDC) — anticipation de
* l'adoption par la CENTIF Côte d'Ivoire.
*
* <p>goAML est l'application de l'Office des Nations Unies contre la Drogue et le Crime (ONUDC)
* adoptée par 60+ FIU dans le monde (Belgique en 2024, Sénégal, etc.). La Côte d'Ivoire n'a pas
* confirmé son adoption au 2026-04-25 mais la CENTIF coopère avec ONUDC. Préparer ce format
* permettra une bascule sans douleur.
*
* <p>Schéma XML : sous-ensemble du <a
* href="https://goaml.unodc.org/goaml/en/index.html">schéma goAML standard</a>. Implémentation
* minimale conforme au noyau {@code Report} → {@code Transaction}.
*
* @since 2026-04-25 (P2-NEW-4)
*/
@ApplicationScoped
public class GoAmlXmlService {
private static final DateTimeFormatter ISO = DateTimeFormatter.ISO_LOCAL_DATE;
private static final DateTimeFormatter ISO_DT = DateTimeFormatter.ISO_LOCAL_DATE_TIME;
@Inject AuditTrailService auditTrail;
/** Génère un fichier XML goAML à partir des données DOS UnionFlow. */
@Transactional
public byte[] genererXml(DosCentifData data) throws IOException {
if (data == null) throw new IllegalArgumentException("DosCentifData null");
try (ByteArrayOutputStream out = new ByteArrayOutputStream()) {
XMLStreamWriter w = XMLOutputFactory.newInstance().createXMLStreamWriter(out, "UTF-8");
w.writeStartDocument("UTF-8", "1.0");
w.writeStartElement("report");
w.writeAttribute("xmlns:xsi", "http://www.w3.org/2001/XMLSchema-instance");
w.writeAttribute("xsi:noNamespaceSchemaLocation", "goAML_schema_5.0.1.xsd");
writeElement(w, "rentity_id", data.numeroDosInterne());
writeElement(w, "rentity_branch", data.institutionDenomination());
writeElement(w, "submission_code", "E"); // E = Electronic
writeElement(w, "report_code", "STR"); // STR = Suspicious Transaction Report
writeElement(w, "submission_date",
data.dateDeclaration() != null ? data.dateDeclaration().format(ISO_DT) : "");
writeElement(w, "currency_code_local", "XOF");
// Reporting person (anonymisé envers le procureur, identifié pour la CENTIF)
w.writeStartElement("reporting_person");
writeElement(w, "first_name", "[REDACTED]");
writeElement(w, "last_name", "[REDACTED]");
writeElement(w, "title", "Compliance Officer");
writeElement(w, "phone", "");
w.writeEndElement();
// Location
w.writeStartElement("location");
writeElement(w, "address_type", "1");
writeElement(w, "address", data.institutionAdresse() == null ? "" : data.institutionAdresse());
writeElement(w, "city", data.institutionVille() == null ? "" : data.institutionVille());
writeElement(w, "country_code", "CIV");
w.writeEndElement();
// Transactions
if (data.operations() != null) {
for (DosOperation op : data.operations()) {
w.writeStartElement("transaction");
writeElement(w, "transactionnumber", data.numeroDosInterne() + "-"
+ (op.date() != null ? op.date().format(ISO) : "n/a"));
writeElement(w, "transaction_location", "");
writeElement(w, "date_transaction", op.date() != null ? op.date().format(ISO) : "");
writeElement(w, "transmode_code", op.typeOperation());
writeElement(w, "amount_local", op.montant() != null ? op.montant().toPlainString() : "0");
writeElement(w, "amount_other_currency", op.montant() != null ? op.montant().toPlainString() : "0");
// Bénéficiaire (simplifié — goAML attend des structures plus riches)
w.writeStartElement("t_to_my_client");
writeElement(w, "to_funds_code", "K"); // K = others / cash equivalent
w.writeStartElement("to_account");
writeElement(w, "account_name", op.beneficiaire() == null ? "" : op.beneficiaire());
writeElement(w, "currency_code", op.devise() == null ? "XOF" : op.devise());
w.writeEndElement();
w.writeEndElement();
w.writeEndElement(); // transaction
}
}
writeElement(w, "report_indicators", data.motifsSoupcon() == null ? "" : data.motifsSoupcon());
writeElement(w, "reason", data.motifsSoupcon() == null ? "" : data.motifsSoupcon());
writeElement(w, "action", data.mesuresConservatoires() == null ? "" : data.mesuresConservatoires());
w.writeEndElement(); // report
w.writeEndDocument();
w.flush();
w.close();
auditTrail.logSimple("AlerteAml", data.alerteId(), "EXPORT",
"Génération DOS goAML XML — N° " + data.numeroDosInterne());
return out.toByteArray();
} catch (XMLStreamException e) {
throw new IOException("Erreur génération XML goAML : " + e.getMessage(), e);
}
}
private void writeElement(XMLStreamWriter w, String name, String value) throws XMLStreamException {
w.writeStartElement(name);
w.writeCharacters(value == null ? "" : value);
w.writeEndElement();
}
}

View File

@@ -0,0 +1,252 @@
package dev.lions.unionflow.server.service.compliance;
import dev.lions.unionflow.server.entity.Membre;
import dev.lions.unionflow.server.entity.Organisation;
import dev.lions.unionflow.server.entity.ProcesVerbal;
import dev.lions.unionflow.server.entity.ReferentielComptable;
import dev.lions.unionflow.server.repository.ProcesVerbalRepository;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.persistence.EntityManager;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.Year;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
/**
* Tableau de bord de conformité par organisation.
*
* <p>Agrège les indicateurs critiques pour l'audit AIRMS / BCEAO / ARTCI :
*
* <ul>
* <li><strong>CMU dirigeants</strong> — au moins président, trésorier, secrétaire enrôlés
* <li><strong>AG annuelle</strong> — tenue avant 30 juin chaque année
* <li><strong>Rapports AIRMS</strong> — triple rapport déposé année N-1
* <li><strong>Compliance Officer</strong> — désigné (Instruction BCEAO 001-03-2025)
* <li><strong>FOMUS-CI</strong> — cotisation à jour (mutuelles sociales CI)
* <li><strong>Commissariat aux comptes</strong> — flag obligatoire si seuils atteints
* <li><strong>KYC à jour</strong> — % membres avec dossier KYC validé non expiré
* <li><strong>Formation LBC/FT</strong> — % membres formés dans les 12 derniers mois
* <li><strong>Bénéficiaires effectifs (UBO)</strong> — couverture des dossiers KYC entreprise
* </ul>
*
* @since 2026-04-25 (P1-NEW-7)
*/
@ApplicationScoped
public class ComplianceDashboardService {
@Inject EntityManager em;
@Inject ProcesVerbalRepository pvRepository;
/** Construit le snapshot de conformité pour une organisation. */
public ComplianceSnapshot snapshot(UUID organisationId) {
Organisation org = em.find(Organisation.class, organisationId);
if (org == null) {
throw new IllegalArgumentException("Organisation introuvable : " + organisationId);
}
int anneeActuelle = Year.now().getValue();
int anneeRapport = anneeActuelle - 1; // rapports AIRMS portent sur N-1
return new ComplianceSnapshot(
organisationId,
org.getNom(),
org.getReferentielComptable(),
// Compliance officer
org.getComplianceOfficerId() != null,
// AG annuelle tenue avant 30 juin année courante
verifierAgAnnuelle(organisationId, anneeActuelle),
// Rapport AIRMS déposé
verifierRapportAirms(organisationId, anneeRapport),
// CMU dirigeants
compterDirigeantsAvecCmu(organisationId),
// KYC
calculerTauxKycAJour(organisationId),
// Formation LBC/FT
calculerTauxFormationLbcFt(organisationId),
// CAC
verifierBesoinCommissaireAuxComptes(organisationId, org),
// FOMUS-CI
verifierFomusCiAJour(organisationId),
// UBO
calculerCouvertureUbo(organisationId),
// Score global
calculerScoreGlobal(organisationId, org)
);
}
// ============================================================
// Vérifications individuelles
// ============================================================
private ConformiteIndicateur verifierAgAnnuelle(UUID orgId, int annee) {
LocalDate deadline = LocalDate.of(annee, 6, 30);
List<ProcesVerbal> ags = pvRepository.findByOrganisationAndType(orgId, "AG_ORDINAIRE");
boolean tenue = ags.stream().anyMatch(pv ->
pv.getDateSeance() != null
&& pv.getDateSeance().getYear() == annee
&& !pv.getDateSeance().toLocalDate().isAfter(deadline)
&& "ARCHIVE".equals(pv.getStatut()));
return new ConformiteIndicateur(
tenue ? "OK" : (LocalDate.now().isAfter(deadline) ? "RETARD" : "EN_ATTENTE"),
tenue
? "AG ordinaire " + annee + " tenue et archivée avant " + deadline
: "AG ordinaire " + annee + " non tenue ou non archivée — deadline " + deadline);
}
private ConformiteIndicateur verifierRapportAirms(UUID orgId, int anneeRapport) {
// Heuristique : on vérifie l'existence d'au moins un PV AG_ORDINAIRE qui mentionne le rapport
// (la génération du PDF AIRMS est tracée dans audit_trail_operations EXPORT — vérification simplifiée ici).
Long count = (Long) em.createQuery(
"SELECT COUNT(p) FROM ProcesVerbal p "
+ "WHERE p.organisationId = :org AND p.typeSeance = 'AG_ORDINAIRE' "
+ "AND EXTRACT(YEAR FROM p.dateSeance) = :annee + 1")
.setParameter("org", orgId)
.setParameter("annee", anneeRapport)
.getSingleResult();
boolean ok = count != null && count > 0;
return new ConformiteIndicateur(ok ? "OK" : "EN_ATTENTE",
ok ? "Rapport AIRMS " + anneeRapport + " présenté en AG" :
"Rapport AIRMS " + anneeRapport + " non identifié dans les PV");
}
private int compterDirigeantsAvecCmu(UUID orgId) {
Long count = (Long) em.createQuery(
"SELECT COUNT(DISTINCT m) FROM Membre m "
+ "JOIN MembreOrganisation mo ON mo.membre = m "
+ "WHERE mo.organisation.id = :org "
+ "AND m.numeroCMU IS NOT NULL "
+ "AND mo.actif = true")
.setParameter("org", orgId)
.getSingleResult();
return count == null ? 0 : count.intValue();
}
private BigDecimal calculerTauxKycAJour(UUID orgId) {
// % de membres de l'org avec un kyc_dossier valide non expiré
Long total = (Long) em.createQuery(
"SELECT COUNT(m) FROM MembreOrganisation m "
+ "WHERE m.organisation.id = :org AND m.actif = true")
.setParameter("org", orgId)
.getSingleResult();
if (total == null || total == 0) return BigDecimal.ZERO;
// Membres de l'org dont le KYC est validé non expiré
Long ok = (Long) em.createQuery(
"SELECT COUNT(DISTINCT mo.membre) FROM MembreOrganisation mo, KycDossier k "
+ "WHERE mo.organisation.id = :org AND mo.actif = true "
+ "AND k.membre = mo.membre "
+ "AND k.statut = 'VALIDE' "
+ "AND (k.dateExpirationPiece IS NULL OR k.dateExpirationPiece > CURRENT_DATE)")
.setParameter("org", orgId)
.getSingleResult();
return BigDecimal.valueOf(ok == null ? 0 : ok)
.multiply(BigDecimal.valueOf(100))
.divide(BigDecimal.valueOf(total), 2, java.math.RoundingMode.HALF_UP);
}
private BigDecimal calculerTauxFormationLbcFt(UUID orgId) {
// Formation LBC/FT obligatoire annuelle (Instruction BCEAO 001-03-2025).
// Implémenté dans Sprint 3 — à raffiner quand entité FormationLbcFt sera disponible.
// Pour l'instant : retourne 0 (à compléter dans P1-NEW-12 ci-dessous).
return BigDecimal.ZERO;
}
private ConformiteIndicateur verifierBesoinCommissaireAuxComptes(UUID orgId, Organisation org) {
// Seuil OHADA AUDSCGIE : 2/3 (bilan > 125M, CA > 250M, effectif > 50)
// Pour SFD : article 44 — encours ≥ 2 milliards FCFA
boolean isSfd = org.getReferentielComptable() == ReferentielComptable.PCSFD_UMOA;
if (isSfd) {
return new ConformiteIndicateur("OBLIGATOIRE",
"SFD article 44 : Commissaire aux comptes obligatoire (encours ≥ 2 Md FCFA)");
}
return new ConformiteIndicateur("OPTIONNEL",
"Vérification à mener : 2/3 seuils AUDSCGIE (bilan 125M, CA 250M, effectif 50)");
}
private ConformiteIndicateur verifierFomusCiAJour(UUID orgId) {
// FOMUS-CI obligatoire pour mutuelles sociales CI (Ordonnance 2024-1043)
// Modalités exactes non publiées au 2026-04-25 → veille
return new ConformiteIndicateur("EN_VEILLE",
"FOMUS-CI : modalités cotisation à confirmer (arrêté ministériel attendu)");
}
private BigDecimal calculerCouvertureUbo(UUID orgId) {
// % de dossiers KYC de l'org ayant au moins un UBO renseigné
Long totalKyc = (Long) em.createQuery(
"SELECT COUNT(DISTINCT k) FROM KycDossier k, MembreOrganisation mo "
+ "WHERE mo.organisation.id = :org AND k.membre = mo.membre AND mo.actif = true")
.setParameter("org", orgId)
.getSingleResult();
if (totalKyc == null || totalKyc == 0) return BigDecimal.ZERO;
Long avecUbo = (Long) em.createQuery(
"SELECT COUNT(DISTINCT u.kycDossier) FROM BeneficiaireEffectif u, MembreOrganisation mo "
+ "WHERE mo.organisation.id = :org AND u.kycDossier.membre = mo.membre AND mo.actif = true")
.setParameter("org", orgId)
.getSingleResult();
return BigDecimal.valueOf(avecUbo == null ? 0 : avecUbo)
.multiply(BigDecimal.valueOf(100))
.divide(BigDecimal.valueOf(totalKyc), 2, java.math.RoundingMode.HALF_UP);
}
/** Score global = moyenne pondérée des indicateurs (0-100). */
private int calculerScoreGlobal(UUID orgId, Organisation org) {
int score = 0;
int max = 0;
// Compliance officer (poids 20)
max += 20;
if (org.getComplianceOfficerId() != null) score += 20;
// AG (poids 25)
max += 25;
ConformiteIndicateur ag = verifierAgAnnuelle(orgId, Year.now().getValue());
if ("OK".equals(ag.statut())) score += 25;
// KYC (poids 25)
max += 25;
BigDecimal tauxKyc = calculerTauxKycAJour(orgId);
score += tauxKyc.multiply(BigDecimal.valueOf(25)).divide(BigDecimal.valueOf(100), 0, java.math.RoundingMode.HALF_UP).intValue();
// Référentiel comptable correctement appliqué (poids 15)
max += 15;
if (org.getReferentielComptable() != null) score += 15;
// CMU dirigeants (poids 15)
max += 15;
if (compterDirigeantsAvecCmu(orgId) >= 3) score += 15; // ≥ 3 dirigeants enrôlés
return max == 0 ? 0 : (score * 100) / max;
}
// ============================================================
// DTOs
// ============================================================
public record ComplianceSnapshot(
UUID organisationId,
String organisationNom,
ReferentielComptable referentielComptable,
boolean complianceOfficerDesigne,
ConformiteIndicateur agAnnuelle,
ConformiteIndicateur rapportAirms,
int dirigeantsAvecCmu,
BigDecimal tauxKycAJourPct,
BigDecimal tauxFormationLbcFtPct,
ConformiteIndicateur commissaireAuxComptes,
ConformiteIndicateur fomusCi,
BigDecimal couvertureUboPct,
int scoreGlobal
) {}
public record ConformiteIndicateur(
String statut, // OK, EN_ATTENTE, RETARD, OPTIONNEL, OBLIGATOIRE, EN_VEILLE
String message
) {}
}

View File

@@ -0,0 +1,118 @@
package dev.lions.unionflow.server.service.formation;
import dev.lions.unionflow.server.entity.FormationLbcFt;
import dev.lions.unionflow.server.entity.ParticipationFormationLbcFt;
import dev.lions.unionflow.server.repository.FormationLbcFtRepository;
import dev.lions.unionflow.server.repository.ParticipationFormationLbcFtRepository;
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 jakarta.ws.rs.WebApplicationException;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.time.Year;
import java.util.UUID;
import org.jboss.logging.Logger;
/**
* Service de gestion des formations LBC/FT obligatoires.
*
* <p>Implémente l'obligation de l'<strong>Instruction BCEAO 001-03-2025</strong> :
*
* <ul>
* <li>Compliance officer doit être formé annuellement
* <li>Dirigeants (président, trésorier, secrétaire) doivent être formés annuellement
* <li>Tous les membres exposés (commissaires aux comptes, contrôleurs internes) idem
* </ul>
*
* <p>Le compliance dashboard ({@link
* dev.lions.unionflow.server.service.compliance.ComplianceDashboardService}) consultera ce
* service pour calculer le taux de membres formés sur 12 mois glissants.
*
* @since 2026-04-25 (P1-NEW-12)
*/
@ApplicationScoped
public class FormationLbcFtService {
private static final Logger LOG = Logger.getLogger(FormationLbcFtService.class);
@Inject FormationLbcFtRepository formationRepo;
@Inject ParticipationFormationLbcFtRepository participationRepo;
@Inject AuditTrailService auditTrail;
/** Crée une nouvelle session de formation. */
@Transactional
public FormationLbcFt creer(FormationLbcFt formation) {
if (formation.getAnneeReference() == null) {
formation.setAnneeReference(Year.now().getValue());
}
formationRepo.persist(formation);
auditTrail.logSimple("FormationLbcFt", formation.getId(), "CREATE",
"Création formation LBC/FT — " + formation.getTitre()
+ " (" + formation.getAnneeReference() + ")");
return formation;
}
/** Inscrit un membre à une formation. */
@Transactional
public ParticipationFormationLbcFt inscrire(UUID formationId, UUID membreId) {
FormationLbcFt formation = formationRepo.findById(formationId);
if (formation == null) {
throw new NotFoundException("Formation introuvable : " + formationId);
}
ParticipationFormationLbcFt p = ParticipationFormationLbcFt.builder()
.formation(formation)
.membreId(membreId)
.statutParticipation("INSCRIT")
.build();
participationRepo.persist(p);
return p;
}
/** Marque un participant comme PRESENT. */
@Transactional
public ParticipationFormationLbcFt marquerPresent(UUID participationId) {
ParticipationFormationLbcFt p = participationRepo.findById(participationId);
if (p == null) throw new NotFoundException("Participation introuvable : " + participationId);
p.setStatutParticipation("PRESENT");
participationRepo.persist(p);
return p;
}
/**
* Délivre le certificat de formation après réussite (PRESENT + score quiz ≥ 70%).
*
* @param scoreQuiz score sur 100 (peut être null si pas de quiz)
*/
@Transactional
public ParticipationFormationLbcFt certifier(UUID participationId, BigDecimal scoreQuiz) {
ParticipationFormationLbcFt p = participationRepo.findById(participationId);
if (p == null) throw new NotFoundException("Participation introuvable : " + participationId);
if (!"PRESENT".equals(p.getStatutParticipation())) {
throw new WebApplicationException(
"Participant doit être PRESENT avant certification", 400);
}
if (scoreQuiz != null && scoreQuiz.compareTo(new BigDecimal("70")) < 0) {
throw new WebApplicationException(
"Score quiz < 70 — certification refusée (score=" + scoreQuiz + ")", 400);
}
p.setStatutParticipation("CERTIFIE");
p.setDateCertification(LocalDateTime.now());
p.setScoreQuiz(scoreQuiz);
p.setNumeroCertificat("CERT-LBCFT-" + p.getId().toString().substring(0, 8).toUpperCase());
participationRepo.persist(p);
auditTrail.logSimple("ParticipationFormationLbcFt", p.getId(), "APPROVE",
"Certification LBC/FT délivrée — " + p.getNumeroCertificat());
return p;
}
/** Vérifie si un membre est à jour pour l'année courante. */
public boolean estCertifieAnneeCourante(UUID membreId) {
return participationRepo.trouverCertificationAnnee(membreId, Year.now().getValue()).isPresent();
}
}

View File

@@ -0,0 +1,106 @@
package dev.lions.unionflow.server.service.pep;
import jakarta.enterprise.context.ApplicationScoped;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
/**
* Implémentation par défaut de {@link PepScreeningProvider} basée sur une liste interne.
*
* <p>À utiliser <strong>uniquement en mode dev / fallback</strong>. En production, remplacer
* par un fournisseur commercial via {@code @Alternative @Priority}.
*
* <p>La liste interne contient quelques PEP UEMOA notoires (élus, hauts fonctionnaires) à titre
* d'exemple. Le maintenir en mode prod n'est pas réaliste — aller chez Youverify / ComplyAdvantage.
*
* @since 2026-04-25 (P1-NEW-11)
*/
@ApplicationScoped
public class InMemoryPepScreeningProvider implements PepScreeningProvider {
/** Seuil de similarité Levenshtein normalisé pour matcher. */
private static final double SIMILARITY_THRESHOLD = 0.80;
/** Liste interne minimale (à maintenir manuellement — non exhaustive). */
private final List<PepMatch> staticList = new ArrayList<>();
public InMemoryPepScreeningProvider() {
// Seed minimal pour test (à compléter via configuration externe en prod).
// Note : ces données sont fictives à titre de démonstration. Aucune assertion sur des
// personnes réelles. La vraie liste doit venir d'un fournisseur licencié.
staticList.add(new PepMatch(
"EXEMPLE Demo Pep", "Exemple Fonction Publique", "CIV",
LocalDate.of(2020, 1, 1), null,
"MINISTER", "InMemoryPepScreeningProvider (DEV ONLY)", 1.0));
}
@Override
public PepScreeningResult screen(String nom, String prenoms, LocalDate dateNaissance,
String nationalite) {
if (nom == null || nom.isBlank() || prenoms == null || prenoms.isBlank()) {
return PepScreeningResult.notFound(providerName());
}
String requete = (nom + " " + prenoms).toLowerCase(Locale.ROOT).trim();
List<PepMatch> matches = new ArrayList<>();
for (PepMatch candidate : staticList) {
String candidateName = candidate.nomComplet().toLowerCase(Locale.ROOT);
double similarity = computeSimilarity(requete, candidateName);
if (similarity >= SIMILARITY_THRESHOLD) {
if (nationalite == null
|| candidate.pays() == null
|| candidate.pays().equalsIgnoreCase(nationalite)) {
matches.add(new PepMatch(
candidate.nomComplet(), candidate.fonction(), candidate.pays(),
candidate.dateDebutFonction(), candidate.dateFinFonction(),
candidate.categorie(), candidate.source(), similarity));
}
}
}
matches.sort(Comparator.comparingDouble(PepMatch::scoreSimilarite).reversed());
String warning = matches.isEmpty()
? null
: "Provider InMemory : liste non exhaustive — résultat indicatif uniquement";
return new PepScreeningResult(!matches.isEmpty(), matches, providerName(), warning);
}
@Override
public String providerName() {
return "InMemory";
}
@Override
public boolean isAvailable() {
return true;
}
/**
* Similarité Jaro-Winkler simplifiée (1 - Levenshtein normalisé). Pour un screening
* banking-grade, utiliser une vraie lib (Apache Commons Text JaroWinklerDistance).
*/
static double computeSimilarity(String s1, String s2) {
int max = Math.max(s1.length(), s2.length());
if (max == 0) return 0.0;
if (s1.equals(s2)) return 1.0;
int distance = levenshtein(s1, s2);
return 1.0 - ((double) distance / max);
}
private static int levenshtein(String s1, String s2) {
int[][] dp = new int[s1.length() + 1][s2.length() + 1];
for (int i = 0; i <= s1.length(); i++) dp[i][0] = i;
for (int j = 0; j <= s2.length(); j++) dp[0][j] = j;
for (int i = 1; i <= s1.length(); i++) {
for (int j = 1; j <= s2.length(); j++) {
int cost = (s1.charAt(i - 1) == s2.charAt(j - 1)) ? 0 : 1;
dp[i][j] = Math.min(Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1), dp[i - 1][j - 1] + cost);
}
}
return dp[s1.length()][s2.length()];
}
}

View File

@@ -0,0 +1,68 @@
package dev.lions.unionflow.server.service.pep;
import java.time.LocalDate;
import java.util.List;
/**
* Interface des fournisseurs de PEP (Personnes Exposées Politiquement) screening.
*
* <p>Aucune liste PEP publique officielle UEMOA/CI au 2026-04-25 → recommandation : intégrer un
* fournisseur SaaS commercial (Youverify, ComplyAdvantage, Refinitiv, Dow Jones, LexisNexis).
*
* <p>Plusieurs implémentations sont prévues :
*
* <ul>
* <li>{@link InMemoryPepScreeningProvider} — liste statique maintenue en interne (mode
* fallback / dev / petite organisation)
* <li>{@code YouverifyPepScreeningProvider} (à créer post-contractualisation Youverify)
* <li>{@code ComplyAdvantagePepScreeningProvider} (alternatif)
* </ul>
*
* <p>Référence : Instruction BCEAO 003-03-2025 du 18 mars 2025 — vérification systématique des
* PEP requise dans le KYC.
*
* @since 2026-04-25 (P1-NEW-11)
*/
public interface PepScreeningProvider {
/**
* Vérifie si une personne figure parmi les PEP référencés.
*
* @param nom nom de famille
* @param prenoms prénoms
* @param dateNaissance date de naissance (peut être null pour recherche partielle)
* @param nationalite code ISO 3166-1 alpha-3
* @return résultat du screening (jamais null)
*/
PepScreeningResult screen(String nom, String prenoms, LocalDate dateNaissance, String nationalite);
/** Identifiant du fournisseur (pour logs / audit). */
String providerName();
/** True si le provider est disponible (configuré, accessible réseau). */
boolean isAvailable();
/** Résultat du screening PEP. */
record PepScreeningResult(
boolean estPep,
List<PepMatch> matches,
String providerUtilise,
String warningMessage
) {
public static PepScreeningResult notFound(String provider) {
return new PepScreeningResult(false, List.of(), provider, null);
}
}
/** Détail d'un match PEP. */
record PepMatch(
String nomComplet,
String fonction,
String pays,
LocalDate dateDebutFonction,
LocalDate dateFinFonction,
String categorie, // ex: HEAD_OF_STATE, MINISTER, MP, JUDICIARY, MILITARY, etc.
String source,
double scoreSimilarite // 0.0 à 1.0
) {}
}

View File

@@ -0,0 +1,82 @@
package dev.lions.unionflow.server.service.pep;
import dev.lions.unionflow.server.security.AuditTrailService;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDate;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.UUID;
/**
* Service de screening PEP avec cache 24 h et audit trail.
*
* <p>Délègue au {@link PepScreeningProvider} configuré (par défaut {@link
* InMemoryPepScreeningProvider}). Toute requête de screening est tracée dans {@code
* audit_trail_operations} (action_type {@code KYC_VALIDE} ou {@code SOD_OVERRIDE} si match
* critique).
*
* <p>Cache 24 h pour éviter de surconsommer les API commerciales (Youverify facture par
* requête).
*
* @since 2026-04-25 (P1-NEW-11)
*/
@ApplicationScoped
public class PepScreeningService {
private static final Duration CACHE_TTL = Duration.ofHours(24);
private static final int CACHE_MAX_SIZE = 5_000;
@Inject PepScreeningProvider provider;
@Inject AuditTrailService auditTrail;
private final Map<String, CachedResult> cache = new LinkedHashMap<>(CACHE_MAX_SIZE) {
@Override
protected boolean removeEldestEntry(Map.Entry<String, CachedResult> eldest) {
return size() > CACHE_MAX_SIZE;
}
};
/**
* Vérifie si une personne est PEP. Utilise le cache 24 h, puis le provider.
*
* @param entityId UUID du membre/UBO concerné (pour audit trail)
*/
public synchronized PepScreeningProvider.PepScreeningResult verifierPep(
UUID entityId, String nom, String prenoms, LocalDate dateNaissance, String nationalite) {
String key = cacheKey(nom, prenoms, dateNaissance, nationalite);
CachedResult cached = cache.get(key);
Instant now = Instant.now();
if (cached != null && cached.expiresAt.isAfter(now)) {
return cached.result;
}
PepScreeningProvider.PepScreeningResult result =
provider.screen(nom, prenoms, dateNaissance, nationalite);
cache.put(key, new CachedResult(result, now.plus(CACHE_TTL)));
if (result.estPep()) {
auditTrail.logSimple("Membre", entityId, "KYC_VALIDE",
"PEP match détecté via " + provider.providerName()
+ " : " + result.matches().size() + " match(es)");
}
return result;
}
private static String cacheKey(String nom, String prenoms, LocalDate dn, String nat) {
return String.join("|",
nom == null ? "" : nom.toLowerCase(),
prenoms == null ? "" : prenoms.toLowerCase(),
dn == null ? "" : dn.toString(),
nat == null ? "" : nat.toUpperCase());
}
private record CachedResult(
PepScreeningProvider.PepScreeningResult result,
Instant expiresAt
) {}
}

View File

@@ -0,0 +1,87 @@
-- ====================================================================
-- V47 — Sprint 3 P1 (2026-04-25)
-- ====================================================================
-- P1-NEW-12 : Module formation LBC/FT obligatoire annuelle
-- (Instruction BCEAO 001-03-2025 art. compliance officer rattaché DG)
-- P1-NEW-11 : Persistance complémentaire screening PEP
-- ====================================================================
-- 1. Sessions de formation LBC/FT (organisées par l'organisation pour ses membres/dirigeants)
CREATE TABLE IF NOT EXISTS formations_lbcft (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organisation_id UUID NOT NULL,
titre VARCHAR(255) NOT NULL,
type_formation VARCHAR(30) NOT NULL DEFAULT 'STANDARD'
CHECK (type_formation IN ('STANDARD', 'AVANCE', 'COMPLIANCE_OFFICER', 'DIRIGEANT')),
contenu TEXT,
intervenant VARCHAR(255),
duree_heures NUMERIC(4,1) NOT NULL DEFAULT 4.0,
date_session TIMESTAMP NOT NULL,
lieu VARCHAR(255),
annee_reference INTEGER NOT NULL, -- année de référence (l'obligation est annuelle)
statut VARCHAR(20) NOT NULL DEFAULT 'PLANIFIEE'
CHECK (statut IN ('PLANIFIEE', 'EN_COURS', 'TERMINEE', 'ANNULEE')),
-- BaseEntity
cree_le TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
cree_par UUID,
modifie_le TIMESTAMP,
modifie_par UUID,
version BIGINT NOT NULL DEFAULT 0,
actif BOOLEAN NOT NULL DEFAULT TRUE
);
CREATE INDEX IF NOT EXISTS idx_formation_org_annee ON formations_lbcft (organisation_id, annee_reference);
CREATE INDEX IF NOT EXISTS idx_formation_date ON formations_lbcft (date_session DESC);
-- 2. Participations aux formations
CREATE TABLE IF NOT EXISTS participations_formation_lbcft (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
formation_id UUID NOT NULL REFERENCES formations_lbcft(id) ON DELETE CASCADE,
membre_id UUID NOT NULL,
statut_participation VARCHAR(20) NOT NULL DEFAULT 'INSCRIT'
CHECK (statut_participation IN ('INSCRIT', 'PRESENT', 'ABSENT', 'CERTIFIE')),
date_certification TIMESTAMP,
numero_certificat VARCHAR(100),
score_quiz NUMERIC(5,2), -- score sur 100 si quiz d'évaluation
cree_le TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
cree_par UUID,
modifie_le TIMESTAMP,
modifie_par UUID,
version BIGINT NOT NULL DEFAULT 0,
actif BOOLEAN NOT NULL DEFAULT TRUE,
UNIQUE (formation_id, membre_id)
);
CREATE INDEX IF NOT EXISTS idx_participation_membre ON participations_formation_lbcft (membre_id);
CREATE INDEX IF NOT EXISTS idx_participation_statut ON participations_formation_lbcft (statut_participation);
COMMENT ON TABLE formations_lbcft IS
'Sessions de formation LBC/FT organisées (Instruction BCEAO 001-03-2025). '
'Obligation annuelle pour compliance officer + dirigeants + membres exposés.';
COMMENT ON TABLE participations_formation_lbcft IS
'Participations individuelles. Statut CERTIFIE prouve la conformité de l''année de référence.';
-- 3. Cache PEP screening (persistant inter-redémarrages, vs cache mémoire 24 h volatile)
CREATE TABLE IF NOT EXISTS pep_screening_cache (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
cache_key VARCHAR(500) NOT NULL UNIQUE, -- nom|prenoms|date|nationalite
provider VARCHAR(50) NOT NULL,
est_pep BOOLEAN NOT NULL,
matches_json JSONB,
expires_at TIMESTAMP NOT NULL,
cree_le TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_pep_cache_expires ON pep_screening_cache (expires_at);
COMMENT ON TABLE pep_screening_cache IS
'Cache de screening PEP — TTL 24 h pour limiter coûts API Youverify/ComplyAdvantage.';

View File

@@ -0,0 +1,43 @@
package dev.lions.unionflow.server.entity;
import static org.assertj.core.api.Assertions.assertThat;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.UUID;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
class FormationLbcFtTest {
@Test
@DisplayName("Builder valeurs par défaut")
void builderDefaults() {
FormationLbcFt f = FormationLbcFt.builder()
.organisationId(UUID.randomUUID())
.titre("Formation annuelle 2026")
.dateSession(LocalDateTime.now())
.anneeReference(2026)
.build();
assertThat(f.getTypeFormation()).isEqualTo("STANDARD");
assertThat(f.getDureeHeures()).isEqualByComparingTo(new BigDecimal("4.0"));
assertThat(f.getStatut()).isEqualTo("PLANIFIEE");
}
@Test
@DisplayName("Type formation override")
void typeFormationOverride() {
FormationLbcFt f = FormationLbcFt.builder()
.organisationId(UUID.randomUUID())
.titre("Compliance Officer Advanced")
.typeFormation("COMPLIANCE_OFFICER")
.dureeHeures(new BigDecimal("16.0"))
.dateSession(LocalDateTime.now())
.anneeReference(2026)
.build();
assertThat(f.getTypeFormation()).isEqualTo("COMPLIANCE_OFFICER");
assertThat(f.getDureeHeures()).isEqualByComparingTo(new BigDecimal("16.0"));
}
}

View File

@@ -0,0 +1,90 @@
package dev.lions.unionflow.server.service.centif;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import dev.lions.unionflow.server.service.centif.DosCentifService.DosCentifData;
import dev.lions.unionflow.server.service.centif.DosCentifService.DosOperation;
import java.io.IOException;
import java.math.BigDecimal;
import java.time.LocalDate;
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;
class GoAmlXmlServiceTest {
private GoAmlXmlService service;
@BeforeEach
void setUp() {
service = new GoAmlXmlService();
service.auditTrail = mock(dev.lions.unionflow.server.security.AuditTrailService.class);
}
private DosCentifData sampleDos() {
return new DosCentifData(
UUID.randomUUID(),
"DOS-2026-001",
LocalDateTime.parse("2026-04-25T10:00:00"),
"Mutuelle Test SFD",
"SFD",
"BCEAO-CIV-2025-001",
"Avenue Test",
"Abidjan",
"PERSONNE_PHYSIQUE",
"Dupont Jean",
LocalDate.of(1980, 6, 15),
"CIV",
"CNI-CIV-12345",
"Commerçant",
false,
null,
List.of(new DosOperation(LocalDate.of(2026, 4, 20), new BigDecimal("12000000"),
"XOF", "VIREMENT", "Beneficiaire X")),
"Pattern de structuration détecté",
"Compte mis sous surveillance",
List.of("releve_compte.pdf"));
}
@Test
@DisplayName("Génère un XML valide non vide")
void genererXmlNonVide() throws IOException {
byte[] xml = service.genererXml(sampleDos());
assertThat(xml).isNotEmpty();
String content = new String(xml);
assertThat(content).startsWith("<?xml version=\"1.0\" encoding=\"UTF-8\"?>");
assertThat(content).contains("<report ");
assertThat(content).contains("</report>");
}
@Test
@DisplayName("XML contient l'ID DOS, déclarant et transaction")
void xmlContientChampsClefs() throws IOException {
String content = new String(service.genererXml(sampleDos()));
assertThat(content).contains("DOS-2026-001");
assertThat(content).contains("Mutuelle Test SFD");
assertThat(content).contains("STR"); // report code
assertThat(content).contains("XOF");
assertThat(content).contains("12000000");
assertThat(content).contains("Pattern de structuration");
}
@Test
@DisplayName("Reporting person anonymisé envers procureur")
void reportingPersonAnonyme() throws IOException {
String content = new String(service.genererXml(sampleDos()));
assertThat(content).contains("[REDACTED]");
assertThat(content).contains("Compliance Officer");
}
@Test
@DisplayName("Pays de l'institution = CIV")
void paysIvoirien() throws IOException {
String content = new String(service.genererXml(sampleDos()));
assertThat(content).contains("<country_code>CIV</country_code>");
}
}

View File

@@ -0,0 +1,77 @@
package dev.lions.unionflow.server.service.pep;
import static org.assertj.core.api.Assertions.assertThat;
import java.time.LocalDate;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
class InMemoryPepScreeningProviderTest {
private final InMemoryPepScreeningProvider provider = new InMemoryPepScreeningProvider();
@Test
@DisplayName("providerName + isAvailable")
void infoBase() {
assertThat(provider.providerName()).isEqualTo("InMemory");
assertThat(provider.isAvailable()).isTrue();
}
@Test
@DisplayName("Match exact retourne PEP=true avec score 1.0")
void matchExact() {
var result = provider.screen("EXEMPLE", "Demo Pep", LocalDate.of(1970, 1, 1), "CIV");
assertThat(result.estPep()).isTrue();
assertThat(result.matches()).hasSize(1);
assertThat(result.matches().get(0).scoreSimilarite()).isEqualTo(1.0);
assertThat(result.warningMessage()).contains("non exhaustive");
}
@Test
@DisplayName("Nationalité différente filtre le match")
void nationaliteDifferente() {
var result = provider.screen("EXEMPLE", "Demo Pep", LocalDate.of(1970, 1, 1), "FRA");
assertThat(result.estPep()).isFalse();
}
@Test
@DisplayName("Aucune nationalité spécifiée → match toujours")
void nationaliteAbsenteMatch() {
var result = provider.screen("EXEMPLE", "Demo Pep", null, null);
assertThat(result.estPep()).isTrue();
}
@Test
@DisplayName("Nom inconnu retourne PEP=false")
void nomInconnu() {
var result = provider.screen("Inconnu", "Personne", LocalDate.of(1980, 6, 15), "CIV");
assertThat(result.estPep()).isFalse();
assertThat(result.matches()).isEmpty();
}
@Test
@DisplayName("Nom/prénoms vides retourne PEP=false sans erreur")
void nomVide() {
assertThat(provider.screen(null, null, null, null).estPep()).isFalse();
assertThat(provider.screen("", "", null, null).estPep()).isFalse();
}
@Test
@DisplayName("Similarité Levenshtein : strings identiques = 1.0")
void similarityIdentical() {
assertThat(InMemoryPepScreeningProvider.computeSimilarity("abc", "abc")).isEqualTo(1.0);
}
@Test
@DisplayName("Similarité Levenshtein : strings vides = 0.0")
void similarityEmpty() {
assertThat(InMemoryPepScreeningProvider.computeSimilarity("", "")).isEqualTo(0.0);
}
@Test
@DisplayName("Similarité Levenshtein : 1 substitution sur 3 chars ≈ 0.667")
void similarityOneSub() {
assertThat(InMemoryPepScreeningProvider.computeSimilarity("abc", "abd"))
.isCloseTo(0.667, org.assertj.core.data.Offset.offset(0.01));
}
}