feat(p0-2026-04-25): multi-référentiel comptable + UBO + audit trail + SoD + seuils AML
Some checks failed
CI/CD Pipeline / pipeline (push) Failing after 3m11s
Some checks failed
CI/CD Pipeline / pipeline (push) Failing after 3m11s
Sprint 1 P0 (consolidation 2026-04-25, ETAT_PROJET_METIER_2026-04-25.md) : P0-NEW-9/10/11 — Multi-référentiel comptable - enum ReferentielComptable (SYSCOHADA / SYCEBNL / PCSFD_UMOA) - Organisation.referentielComptable + mapping defaultFor(typeOrganisation) - V43 : colonne + check + index + mapping initial des orgs existantes P0-NEW-13 — Bénéficiaires effectifs (UBO) — Instruction BCEAO 003-03-2025 - Entité BeneficiaireEffectif + repository - V44 : table beneficiaires_effectifs (FK kyc_dossier, UBO + PEP + sanctions) - Conservation 10 ans (directive 02/2015/CM/UEMOA) P0-NEW-14 — Compliance Officer (Instruction BCEAO 001-03-2025) - Organisation.complianceOfficerId + V43 colonne + index P0-NEW-15 — Seuils AML alignés (Instruction BCEAO 002-03-2025) - AmlSeuils : 10M FCFA intra-UEMOA / 5M FCFA entrée-sortie / 1M FCFA espèce - Liste pays UEMOA ISO 3166-1 - Méthodes seuilApplicable() / depasseSeuil() / depasseSeuilEspece() P0-NEW-17/18 — Rôles PRESIDENT + CONTROLEUR_INTERNE + suppléants - V45 seed : PRESIDENT, VICE_PRESIDENT, CONTROLEUR_INTERNE, ANIMATEUR_ZONE, SECRETAIRE_ADJOINT, TRESORIER_ADJOINT - Catégories GOUVERNANCE / CONTROLE / OPERATIONNEL P0-NEW-19 — Audit trail enrichi (SYSCOHADA + AUDSCGIE) - V45 : table audit_trail_operations (acteur, action, contexte multi-org, payload JSONB, SoD) - Entité AuditTrailOperation + AuditTrailOperationRepository - AuditTrailService (log avec contexte automatique depuis OrganisationContextHolder) - OrganisationContextHolder enrichi (roleActif, currentUserId, currentUserEmail) P0-NEW-20 — SoD (Separation of Duties) — SYSCOHADA + AUDSCGIE + BCEAO Circulaire 03-2017 - SoDPermissionChecker.checkValidationDistinct() (4-eyes principle) - .checkRoleCombination() (combinaisons interdites : Trésorier+Président, etc.) - .checkComplianceOfficerEligibility() (Instruction BCEAO 001-03-2025) - SoDCheckResult record avec audit trail automatique P0-NEW-24 — Champ numero_cmu sur Membre (Loi 2014-131 CI) - Membre.numeroCMU + V43 colonne + check format 11 caractères + index - Auto-déclaration (pas d'API publique CNAM disponible) BUILD SUCCESS.
This commit is contained in:
@@ -0,0 +1,104 @@
|
|||||||
|
package dev.lions.unionflow.server.entity;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.UUID;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import org.hibernate.annotations.JdbcTypeCode;
|
||||||
|
import org.hibernate.type.SqlTypes;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entrée d'audit trail enrichi (SYSCOHADA + AUDSCGIE OHADA).
|
||||||
|
*
|
||||||
|
* <p>Trace les opérations financières, le lifecycle membres, les changements de configuration,
|
||||||
|
* avec le contexte multi-org (rôle actif + organisation active) + vérifications de séparation des
|
||||||
|
* pouvoirs (SoD).
|
||||||
|
*
|
||||||
|
* <p>Cette entité ne dérive PAS de {@link BaseEntity} car elle représente un enregistrement
|
||||||
|
* immuable d'historique : ses propres champs d'audit ({@code operationAt}, {@code userId}) sont
|
||||||
|
* la donnée à tracer.
|
||||||
|
*
|
||||||
|
* @since 2026-04-25 — exigences SYSCOHADA + Instruction BCEAO 003-03-2025 (audit KYC)
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "audit_trail_operations")
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Builder
|
||||||
|
public class AuditTrailOperation {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.UUID)
|
||||||
|
@Column(name = "id", updatable = false, nullable = false)
|
||||||
|
private UUID id;
|
||||||
|
|
||||||
|
// Acteur
|
||||||
|
@NotNull
|
||||||
|
@Column(name = "user_id", nullable = false)
|
||||||
|
private UUID userId;
|
||||||
|
|
||||||
|
@Column(name = "user_email", length = 255)
|
||||||
|
private String userEmail;
|
||||||
|
|
||||||
|
@Column(name = "role_actif", length = 50)
|
||||||
|
private String roleActif;
|
||||||
|
|
||||||
|
@Column(name = "organisation_active_id")
|
||||||
|
private UUID organisationActiveId;
|
||||||
|
|
||||||
|
// Action
|
||||||
|
@NotBlank
|
||||||
|
@Column(name = "action_type", nullable = false, length = 50)
|
||||||
|
private String actionType;
|
||||||
|
|
||||||
|
@NotBlank
|
||||||
|
@Column(name = "entity_type", nullable = false, length = 100)
|
||||||
|
private String entityType;
|
||||||
|
|
||||||
|
@Column(name = "entity_id")
|
||||||
|
private UUID entityId;
|
||||||
|
|
||||||
|
@Column(name = "description", length = 500)
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
// Contexte
|
||||||
|
@Column(name = "ip_address", length = 45)
|
||||||
|
private String ipAddress;
|
||||||
|
|
||||||
|
@Column(name = "user_agent", length = 500)
|
||||||
|
private String userAgent;
|
||||||
|
|
||||||
|
@Column(name = "request_id")
|
||||||
|
private UUID requestId;
|
||||||
|
|
||||||
|
// Données (JSONB)
|
||||||
|
@JdbcTypeCode(SqlTypes.JSON)
|
||||||
|
@Column(name = "payload_avant", columnDefinition = "jsonb")
|
||||||
|
private String payloadAvant;
|
||||||
|
|
||||||
|
@JdbcTypeCode(SqlTypes.JSON)
|
||||||
|
@Column(name = "payload_apres", columnDefinition = "jsonb")
|
||||||
|
private String payloadApres;
|
||||||
|
|
||||||
|
@JdbcTypeCode(SqlTypes.JSON)
|
||||||
|
@Column(name = "metadata", columnDefinition = "jsonb")
|
||||||
|
private String metadata;
|
||||||
|
|
||||||
|
// SoD
|
||||||
|
@Column(name = "sod_check_passed")
|
||||||
|
private Boolean sodCheckPassed;
|
||||||
|
|
||||||
|
@Column(name = "sod_violations", length = 500)
|
||||||
|
private String sodViolations;
|
||||||
|
|
||||||
|
@NotNull
|
||||||
|
@Column(name = "operation_at", nullable = false)
|
||||||
|
@Builder.Default
|
||||||
|
private LocalDateTime operationAt = LocalDateTime.now();
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
package dev.lions.unionflow.server.entity;
|
||||||
|
|
||||||
|
import dev.lions.unionflow.server.api.enums.membre.TypePieceIdentite;
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import jakarta.validation.constraints.*;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.UUID;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.EqualsAndHashCode;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bénéficiaire effectif (UBO — Ultimate Beneficial Owner) lié à un dossier KYC.
|
||||||
|
*
|
||||||
|
* <p>Implémente l'obligation introduite par l'<strong>Instruction BCEAO 003-03-2025 du 18 mars
|
||||||
|
* 2025</strong> : identification, vérification et connaissance du client par les institutions
|
||||||
|
* financières — vérification systématique des bénéficiaires effectifs obligatoire (approche par
|
||||||
|
* les risques).
|
||||||
|
*
|
||||||
|
* <p>Un bénéficiaire effectif est, selon la directive UEMOA et le GAFI/FATF, toute personne
|
||||||
|
* physique qui :
|
||||||
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li>détient au moins <strong>25 %</strong> du capital ou des droits de vote d'une personne
|
||||||
|
* morale ;
|
||||||
|
* <li>OU exerce un contrôle effectif (de fait ou de droit) sur la gestion de l'entité ;
|
||||||
|
* <li>OU est bénéficiaire ultime d'une opération suspecte structurée.
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>Ces enregistrements doivent être conservés <strong>10 ans</strong> après la clôture de la
|
||||||
|
* relation d'affaires (directive 02/2015/CM/UEMOA).
|
||||||
|
*
|
||||||
|
* @since 2026-04-25 — Instruction BCEAO 003-03-2025 (KYC + UBO)
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(
|
||||||
|
name = "beneficiaires_effectifs",
|
||||||
|
indexes = {
|
||||||
|
@Index(name = "idx_ubo_kyc_dossier", columnList = "kyc_dossier_id"),
|
||||||
|
@Index(name = "idx_ubo_organisation_cible", columnList = "organisation_cible_id"),
|
||||||
|
@Index(name = "idx_ubo_pays", columnList = "pays_residence")
|
||||||
|
})
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Builder
|
||||||
|
@EqualsAndHashCode(callSuper = true)
|
||||||
|
public class BeneficiaireEffectif extends BaseEntity {
|
||||||
|
|
||||||
|
/** Dossier KYC auquel ce bénéficiaire effectif est rattaché. */
|
||||||
|
@NotNull
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "kyc_dossier_id", nullable = false)
|
||||||
|
private KycDossier kycDossier;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Organisation cible dont cette personne est bénéficiaire effectif (utile en cas de KYC client
|
||||||
|
* personne morale — la chaîne de contrôle UBO peut traverser plusieurs entités).
|
||||||
|
*/
|
||||||
|
@Column(name = "organisation_cible_id")
|
||||||
|
private UUID organisationCibleId;
|
||||||
|
|
||||||
|
/** Lien vers le membre UnionFlow correspondant si applicable (UBO interne au système). */
|
||||||
|
@Column(name = "membre_id")
|
||||||
|
private UUID membreId;
|
||||||
|
|
||||||
|
// Identité
|
||||||
|
|
||||||
|
@NotBlank
|
||||||
|
@Column(name = "nom", nullable = false, length = 100)
|
||||||
|
private String nom;
|
||||||
|
|
||||||
|
@NotBlank
|
||||||
|
@Column(name = "prenoms", nullable = false, length = 200)
|
||||||
|
private String prenoms;
|
||||||
|
|
||||||
|
@NotNull
|
||||||
|
@Column(name = "date_naissance", nullable = false)
|
||||||
|
private LocalDate dateNaissance;
|
||||||
|
|
||||||
|
@Column(name = "lieu_naissance", length = 200)
|
||||||
|
private String lieuNaissance;
|
||||||
|
|
||||||
|
@NotBlank
|
||||||
|
@Column(name = "nationalite", nullable = false, length = 3)
|
||||||
|
private String nationalite; // ISO 3166-1 alpha-3
|
||||||
|
|
||||||
|
@Column(name = "pays_residence", length = 3)
|
||||||
|
private String paysResidence;
|
||||||
|
|
||||||
|
// Pièce d'identité
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(name = "type_piece_identite", length = 30)
|
||||||
|
private TypePieceIdentite typePieceIdentite;
|
||||||
|
|
||||||
|
@Column(name = "numero_piece_identite", length = 50)
|
||||||
|
private String numeroPieceIdentite;
|
||||||
|
|
||||||
|
@Column(name = "date_expiration_piece")
|
||||||
|
private LocalDate dateExpirationPiece;
|
||||||
|
|
||||||
|
// Contrôle
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pourcentage de détention en capital (0-100). Si {@code >= 25} → UBO direct selon GAFI.
|
||||||
|
* Peut être null si le contrôle est exercé autrement (mandat, accord d'actionnaires).
|
||||||
|
*/
|
||||||
|
@DecimalMin("0.00")
|
||||||
|
@DecimalMax("100.00")
|
||||||
|
@Column(name = "pourcentage_capital", precision = 5, scale = 2)
|
||||||
|
private BigDecimal pourcentageCapital;
|
||||||
|
|
||||||
|
/** Pourcentage des droits de vote (0-100). */
|
||||||
|
@DecimalMin("0.00")
|
||||||
|
@DecimalMax("100.00")
|
||||||
|
@Column(name = "pourcentage_droits_vote", precision = 5, scale = 2)
|
||||||
|
private BigDecimal pourcentageDroitsVote;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nature du contrôle exercé : DETENTION_CAPITAL, DROITS_VOTE, CONTROLE_DE_FAIT,
|
||||||
|
* BENEFICIAIRE_ULTIME, MANDAT_REPRESENTATION.
|
||||||
|
*/
|
||||||
|
@NotBlank
|
||||||
|
@Column(name = "nature_controle", nullable = false, length = 50)
|
||||||
|
private String natureControle;
|
||||||
|
|
||||||
|
// Politique d'exposition (PEP)
|
||||||
|
|
||||||
|
@Column(name = "est_pep", nullable = false)
|
||||||
|
@Builder.Default
|
||||||
|
private boolean estPep = false;
|
||||||
|
|
||||||
|
@Column(name = "pep_categorie", length = 100)
|
||||||
|
private String pepCategorie;
|
||||||
|
|
||||||
|
@Column(name = "pep_pays", length = 3)
|
||||||
|
private String pepPays;
|
||||||
|
|
||||||
|
@Column(name = "pep_fonction", length = 200)
|
||||||
|
private String pepFonction;
|
||||||
|
|
||||||
|
// Sanctions / vigilance
|
||||||
|
|
||||||
|
@Column(name = "presence_listes_sanctions", nullable = false)
|
||||||
|
@Builder.Default
|
||||||
|
private boolean presenceListesSanctions = false;
|
||||||
|
|
||||||
|
@Column(name = "details_listes_sanctions", length = 1000)
|
||||||
|
private String detailsListesSanctions;
|
||||||
|
|
||||||
|
// Vérification
|
||||||
|
|
||||||
|
@Column(name = "verifie_par_id")
|
||||||
|
private UUID verifieParId;
|
||||||
|
|
||||||
|
@Column(name = "date_verification")
|
||||||
|
private java.time.LocalDateTime dateVerification;
|
||||||
|
|
||||||
|
@Column(name = "source_verification", length = 200)
|
||||||
|
private String sourceVerification;
|
||||||
|
|
||||||
|
@Column(name = "notes", length = 2000)
|
||||||
|
private String notes;
|
||||||
|
}
|
||||||
@@ -63,6 +63,20 @@ public class Membre extends BaseEntity {
|
|||||||
@Column(name = "fcm_token", length = 500)
|
@Column(name = "fcm_token", length = 500)
|
||||||
private String fcmToken;
|
private String fcmToken;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Numéro CMU (Couverture Maladie Universelle) Côte d'Ivoire — auto-déclaré par le membre.
|
||||||
|
*
|
||||||
|
* <p>Obligatoire pour les organisations de type {@code MUTUELLE_SANTE} (Loi 2014-131
|
||||||
|
* exige enrôlement CMU comme préalable à toute mutuelle complémentaire). Format CNAM :
|
||||||
|
* 11 caractères alphanumériques. La vérification de la validité se fait manuellement
|
||||||
|
* (admin) faute d'API publique CNAM disponible au 2026-04-25.
|
||||||
|
*
|
||||||
|
* @since 2026-04-25 — passage CMU à cotisation obligatoire 1er jan 2026
|
||||||
|
*/
|
||||||
|
@Pattern(regexp = "^[A-Z0-9]{11}$|^$", message = "Le numéro CMU doit faire 11 caractères alphanumériques majuscules")
|
||||||
|
@Column(name = "numero_cmu", length = 11)
|
||||||
|
private String numeroCMU;
|
||||||
|
|
||||||
@Pattern(regexp = "^\\+[1-9][0-9]{6,14}$", message = "Le numéro Wave doit être au format international E.164 (ex: +22507XXXXXXXX)")
|
@Pattern(regexp = "^\\+[1-9][0-9]{6,14}$", message = "Le numéro Wave doit être au format international E.164 (ex: +22507XXXXXXXX)")
|
||||||
@Column(name = "telephone_wave", length = 20)
|
@Column(name = "telephone_wave", length = 20)
|
||||||
private String telephoneWave;
|
private String telephoneWave;
|
||||||
|
|||||||
@@ -210,6 +210,30 @@ public class Organisation extends BaseEntity {
|
|||||||
@Column(name = "modules_actifs", length = 1000)
|
@Column(name = "modules_actifs", length = 1000)
|
||||||
private String modulesActifs;
|
private String modulesActifs;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Référentiel comptable applicable à cette organisation.
|
||||||
|
*
|
||||||
|
* <p>Détermine quel plan comptable est appliqué et quels états financiers sont générés
|
||||||
|
* (bilan, compte de résultat, annexes). Mappage par défaut depuis {@code typeOrganisation}
|
||||||
|
* via {@link ReferentielComptable#defaultFor(String)} ; l'admin peut overrider manuellement.
|
||||||
|
*
|
||||||
|
* @since 2026-04-25 — découverte SYCEBNL (11ᵉ Acte uniforme OHADA en vigueur 1er jan 2024)
|
||||||
|
*/
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(name = "referentiel_comptable", nullable = false, length = 20)
|
||||||
|
@Builder.Default
|
||||||
|
private ReferentielComptable referentielComptable = ReferentielComptable.SYSCOHADA;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UUID du membre désigné comme Compliance Officer de l'organisation (rôle obligatoire selon
|
||||||
|
* Instruction BCEAO 001-03-2025). Doit être rattaché à la direction générale, distinct du
|
||||||
|
* trésorier (séparation des pouvoirs).
|
||||||
|
*
|
||||||
|
* @since 2026-04-25 — Instruction BCEAO 001-03-2025 (LBC/FT)
|
||||||
|
*/
|
||||||
|
@Column(name = "compliance_officer_id")
|
||||||
|
private UUID complianceOfficerId;
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
|
|
||||||
/** Adhésions des membres à cette organisation */
|
/** Adhésions des membres à cette organisation */
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package dev.lions.unionflow.server.entity;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Référentiel comptable applicable à une {@link Organisation}.
|
||||||
|
*
|
||||||
|
* <p>OHADA dispose désormais de plusieurs référentiels selon la nature de l'entité :
|
||||||
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li>{@link #SYSCOHADA} — Système Comptable OHADA révisé (1er jan 2018) pour entités
|
||||||
|
* commerciales/coopératives à but lucratif.
|
||||||
|
* <li>{@link #SYCEBNL} — Système Comptable OHADA des Entités à But Non Lucratif (11ᵉ Acte
|
||||||
|
* uniforme, entré en vigueur <strong>1er jan 2024</strong>) pour mutuelles sociales,
|
||||||
|
* associations, ONG, fondations, syndicats, projets de développement.
|
||||||
|
* <li>{@link #PCSFD_UMOA} — Plan Comptable des Systèmes Financiers Décentralisés UMOA pour SFD
|
||||||
|
* soumis Commission Bancaire UMOA (article 44, encours ≥ 2 milliards FCFA = catégorie III).
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>Le mapping par défaut depuis {@code Organisation.typeOrganisation} se trouve dans
|
||||||
|
* {@link #defaultFor(String)}. L'admin peut overrider manuellement (cas hybrides).
|
||||||
|
*
|
||||||
|
* <p>Voir : {@code unionflow/docs/COMPLIANCE_OHADA_SYCEBNL.md} et {@code
|
||||||
|
* unionflow/docs/COMPLIANCE_OHADA_SYSCOHADA.md}.
|
||||||
|
*
|
||||||
|
* @since 2026-04-25
|
||||||
|
*/
|
||||||
|
public enum ReferentielComptable {
|
||||||
|
/** Système Comptable OHADA révisé (entités commerciales / coopératives lucratives). */
|
||||||
|
SYSCOHADA,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Système Comptable OHADA des Entités à But Non Lucratif (mutuelles sociales, associations,
|
||||||
|
* ONG, fondations, Lions Clubs, syndicats). Acte uniforme entré en vigueur 1er janvier 2024.
|
||||||
|
*/
|
||||||
|
SYCEBNL,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plan Comptable des Systèmes Financiers Décentralisés UMOA. Pour SFD article 44 (encours ≥ 2
|
||||||
|
* Md FCFA = catégorie III, commissaire aux comptes obligatoire agréé OHADA, sélection soumise
|
||||||
|
* approbation Commission Bancaire UMOA).
|
||||||
|
*/
|
||||||
|
PCSFD_UMOA;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retourne le référentiel par défaut suggéré pour un {@code typeOrganisation}. L'admin peut
|
||||||
|
* overrider manuellement à la création/édition d'une organisation.
|
||||||
|
*
|
||||||
|
* @param typeOrganisation valeur de {@link Organisation#getTypeOrganisation()}
|
||||||
|
* @return référentiel par défaut, jamais null (fallback {@link #SYSCOHADA})
|
||||||
|
*/
|
||||||
|
public static ReferentielComptable defaultFor(String typeOrganisation) {
|
||||||
|
if (typeOrganisation == null) {
|
||||||
|
return SYSCOHADA;
|
||||||
|
}
|
||||||
|
return switch (typeOrganisation.toUpperCase()) {
|
||||||
|
case "MUTUELLE_SANTE",
|
||||||
|
"ASSOCIATION",
|
||||||
|
"LIONS_CLUB",
|
||||||
|
"ONG",
|
||||||
|
"FONDATION",
|
||||||
|
"SYNDICAT",
|
||||||
|
"ORDRE_PROFESSIONNEL",
|
||||||
|
"PROJET_DEVELOPPEMENT" ->
|
||||||
|
SYCEBNL;
|
||||||
|
case "SFD_TIER_1", "SFD_CATEGORIE_III" -> PCSFD_UMOA;
|
||||||
|
default -> SYSCOHADA;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package dev.lions.unionflow.server.repository;
|
||||||
|
|
||||||
|
import dev.lions.unionflow.server.entity.AuditTrailOperation;
|
||||||
|
import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase;
|
||||||
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repository Panache pour l'audit trail enrichi.
|
||||||
|
*
|
||||||
|
* @since 2026-04-25
|
||||||
|
*/
|
||||||
|
@ApplicationScoped
|
||||||
|
public class AuditTrailOperationRepository
|
||||||
|
implements PanacheRepositoryBase<AuditTrailOperation, UUID> {
|
||||||
|
|
||||||
|
/** Opérations d'un utilisateur dans une fenêtre temporelle. */
|
||||||
|
public List<AuditTrailOperation> findByUserBetween(UUID userId, LocalDateTime from, LocalDateTime to) {
|
||||||
|
return list("userId = ?1 AND operationAt BETWEEN ?2 AND ?3 ORDER BY operationAt DESC",
|
||||||
|
userId, from, to);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Opérations sur une entité spécifique (ex: pour bouton "voir l'historique"). */
|
||||||
|
public List<AuditTrailOperation> findByEntity(String entityType, UUID entityId) {
|
||||||
|
return list("entityType = ?1 AND entityId = ?2 ORDER BY operationAt DESC",
|
||||||
|
entityType, entityId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Opérations dans le contexte d'une organisation. */
|
||||||
|
public List<AuditTrailOperation> findByOrganisationActive(UUID organisationActiveId) {
|
||||||
|
return list("organisationActiveId = ?1 ORDER BY operationAt DESC", organisationActiveId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Violations SoD détectées (alertes compliance officer). */
|
||||||
|
public List<AuditTrailOperation> findSoDViolations() {
|
||||||
|
return list("sodCheckPassed = false ORDER BY operationAt DESC");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Opérations financières (paiements, budgets, écritures comptables) pour reporting AIRMS. */
|
||||||
|
public List<AuditTrailOperation> findFinancialOperations(UUID organisationId, LocalDateTime from, LocalDateTime to) {
|
||||||
|
return list(
|
||||||
|
"organisationActiveId = ?1 AND operationAt BETWEEN ?2 AND ?3 "
|
||||||
|
+ "AND actionType IN ('PAYMENT_INITIATED', 'PAYMENT_CONFIRMED', 'PAYMENT_FAILED', "
|
||||||
|
+ "'BUDGET_APPROVED', 'AID_REQUEST_APPROVED') "
|
||||||
|
+ "ORDER BY operationAt DESC",
|
||||||
|
organisationId, from, to);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package dev.lions.unionflow.server.repository;
|
||||||
|
|
||||||
|
import dev.lions.unionflow.server.entity.BeneficiaireEffectif;
|
||||||
|
import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase;
|
||||||
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repository Panache pour les bénéficiaires effectifs (UBO).
|
||||||
|
*
|
||||||
|
* @since 2026-04-25
|
||||||
|
*/
|
||||||
|
@ApplicationScoped
|
||||||
|
public class BeneficiaireEffectifRepository
|
||||||
|
implements PanacheRepositoryBase<BeneficiaireEffectif, UUID> {
|
||||||
|
|
||||||
|
/** Bénéficiaires effectifs liés à un dossier KYC. */
|
||||||
|
public List<BeneficiaireEffectif> findByKycDossier(UUID kycDossierId) {
|
||||||
|
return list("kycDossier.id", kycDossierId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bénéficiaires effectifs liés à une organisation cible (chaîne de contrôle UBO). */
|
||||||
|
public List<BeneficiaireEffectif> findByOrganisationCible(UUID organisationCibleId) {
|
||||||
|
return list("organisationCibleId", organisationCibleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** UBOs identifiés comme PEP. */
|
||||||
|
public List<BeneficiaireEffectif> findPep() {
|
||||||
|
return list("estPep", true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** UBOs présents sur des listes de sanctions. */
|
||||||
|
public List<BeneficiaireEffectif> findOnSanctionsLists() {
|
||||||
|
return list("presenceListesSanctions", true);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package dev.lions.unionflow.server.security;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import dev.lions.unionflow.server.entity.AuditTrailOperation;
|
||||||
|
import dev.lions.unionflow.server.repository.AuditTrailOperationRepository;
|
||||||
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
|
import jakarta.inject.Inject;
|
||||||
|
import jakarta.transaction.Transactional;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.jboss.logging.Logger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service d'audit trail enrichi (SYSCOHADA + AUDSCGIE OHADA + Instruction BCEAO 003-03-2025).
|
||||||
|
*
|
||||||
|
* <p>Enregistre toutes les opérations sensibles (financières, lifecycle membres, configurations)
|
||||||
|
* avec leur contexte multi-org complet (rôle actif, organisation active, vérifications SoD).
|
||||||
|
*
|
||||||
|
* <p>Usage typique dans un service métier :
|
||||||
|
*
|
||||||
|
* <pre>{@code
|
||||||
|
* @Inject AuditTrailService auditTrail;
|
||||||
|
*
|
||||||
|
* public Cotisation enregistrerPaiement(...) {
|
||||||
|
* Cotisation c = ...
|
||||||
|
* auditTrail.log("Cotisation", c.getId(), "PAYMENT_CONFIRMED",
|
||||||
|
* "Paiement confirmé via " + provider, c);
|
||||||
|
* return c;
|
||||||
|
* }
|
||||||
|
* }</pre>
|
||||||
|
*
|
||||||
|
* @since 2026-04-25
|
||||||
|
*/
|
||||||
|
@ApplicationScoped
|
||||||
|
public class AuditTrailService {
|
||||||
|
|
||||||
|
private static final Logger LOG = Logger.getLogger(AuditTrailService.class);
|
||||||
|
|
||||||
|
@Inject AuditTrailOperationRepository repository;
|
||||||
|
@Inject OrganisationContextHolder context;
|
||||||
|
|
||||||
|
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enregistre une entrée d'audit trail à partir du contexte courant.
|
||||||
|
*
|
||||||
|
* @param entityType nom de l'entité (ex: "Cotisation", "Membre", "EcritureComptable")
|
||||||
|
* @param entityId UUID de l'entité ciblée (peut être null pour actions globales)
|
||||||
|
* @param actionType type d'action (cf. CHECK SQL : CREATE, UPDATE, DELETE, APPROVE, ...)
|
||||||
|
* @param description description libre courte (≤ 500 caractères)
|
||||||
|
* @param payloadApres entité après modification (sérialisée JSON, peut être null)
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void log(String entityType, UUID entityId, String actionType, String description,
|
||||||
|
Object payloadApres) {
|
||||||
|
log(entityType, entityId, actionType, description, null, payloadApres, null, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enregistre une entrée d'audit trail avec snapshot avant/après et résultat SoD.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void log(String entityType, UUID entityId, String actionType, String description,
|
||||||
|
Object payloadAvant, Object payloadApres, Object metadata,
|
||||||
|
Boolean sodCheckPassed, String sodViolations) {
|
||||||
|
try {
|
||||||
|
AuditTrailOperation entry = AuditTrailOperation.builder()
|
||||||
|
.userId(context.getCurrentUserId())
|
||||||
|
.userEmail(context.getCurrentUserEmail())
|
||||||
|
.roleActif(context.getRoleActif())
|
||||||
|
.organisationActiveId(context.getOrganisationId())
|
||||||
|
.actionType(actionType)
|
||||||
|
.entityType(entityType)
|
||||||
|
.entityId(entityId)
|
||||||
|
.description(description)
|
||||||
|
.payloadAvant(toJson(payloadAvant))
|
||||||
|
.payloadApres(toJson(payloadApres))
|
||||||
|
.metadata(toJson(metadata))
|
||||||
|
.sodCheckPassed(sodCheckPassed)
|
||||||
|
.sodViolations(sodViolations)
|
||||||
|
.operationAt(LocalDateTime.now())
|
||||||
|
.build();
|
||||||
|
repository.persist(entry);
|
||||||
|
} catch (Exception e) {
|
||||||
|
// Fail-soft : l'audit trail ne doit jamais bloquer une opération métier.
|
||||||
|
// Les violations sont loguées et peuvent être détectées via les logs applicatifs.
|
||||||
|
LOG.errorf(e,
|
||||||
|
"Audit trail log failed: entityType=%s entityId=%s actionType=%s description=%s",
|
||||||
|
entityType, entityId, actionType, description);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Variante sans payload — pour les actions simples (LOGIN, LOGOUT, EXPORT...). */
|
||||||
|
@Transactional
|
||||||
|
public void logSimple(String entityType, UUID entityId, String actionType, String description) {
|
||||||
|
log(entityType, entityId, actionType, description, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String toJson(Object o) {
|
||||||
|
if (o == null) return null;
|
||||||
|
try {
|
||||||
|
return objectMapper.writeValueAsString(o);
|
||||||
|
} catch (Exception e) {
|
||||||
|
LOG.warnf("Audit trail JSON serialization failed for %s : %s",
|
||||||
|
o.getClass().getSimpleName(), e.getMessage());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,39 @@ public class OrganisationContextHolder {
|
|||||||
private Organisation organisation;
|
private Organisation organisation;
|
||||||
private boolean resolved = false;
|
private boolean resolved = false;
|
||||||
|
|
||||||
|
/** Rôle actif sélectionné par le user pour cette requête (header X-Active-Role). */
|
||||||
|
private String roleActif;
|
||||||
|
|
||||||
|
/** UUID de l'utilisateur courant (sub du JWT). */
|
||||||
|
private UUID currentUserId;
|
||||||
|
|
||||||
|
/** Email de l'utilisateur courant (claim email du JWT). */
|
||||||
|
private String currentUserEmail;
|
||||||
|
|
||||||
|
public String getRoleActif() {
|
||||||
|
return roleActif;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRoleActif(String roleActif) {
|
||||||
|
this.roleActif = roleActif;
|
||||||
|
}
|
||||||
|
|
||||||
|
public UUID getCurrentUserId() {
|
||||||
|
return currentUserId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCurrentUserId(UUID currentUserId) {
|
||||||
|
this.currentUserId = currentUserId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getCurrentUserEmail() {
|
||||||
|
return currentUserEmail;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCurrentUserEmail(String currentUserEmail) {
|
||||||
|
this.currentUserEmail = currentUserEmail;
|
||||||
|
}
|
||||||
|
|
||||||
public UUID getOrganisationId() {
|
public UUID getOrganisationId() {
|
||||||
return organisationId;
|
return organisationId;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
package dev.lions.unionflow.server.security;
|
||||||
|
|
||||||
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
|
import jakarta.inject.Inject;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.jboss.logging.Logger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérificateur de séparation des pouvoirs (Separation of Duties — SoD).
|
||||||
|
*
|
||||||
|
* <p>Implémente les règles SoD exigées par :
|
||||||
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li><strong>SYSCOHADA / AUDCIF</strong> — traçabilité écritures comptables (chaque opération
|
||||||
|
* = pièce datée identifiable conservée), contrôle interne obligatoire ;
|
||||||
|
* <li><strong>AUDSCGIE OHADA</strong> — séparation CA décide / gérant exécute / Commissaire aux
|
||||||
|
* comptes contrôle ;
|
||||||
|
* <li><strong>BCEAO Circulaire 03-2017/CB/C</strong> — contrôle interne SFD UMOA (gouvernance,
|
||||||
|
* gestion risques, conformité, audit interne) ;
|
||||||
|
* <li><strong>Instruction BCEAO 001-03-2025</strong> — Compliance Officer rattaché DG, distinct
|
||||||
|
* trésorier/président.
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>Les règles principales :
|
||||||
|
*
|
||||||
|
* <ol>
|
||||||
|
* <li>Un même utilisateur ne peut pas <strong>créer une dépense ET la valider</strong> (4-eyes
|
||||||
|
* principle pour engagement → ordonnancement → paiement).
|
||||||
|
* <li>Le <strong>Compliance Officer</strong> ne peut pas être simultanément trésorier ou
|
||||||
|
* président.
|
||||||
|
* <li>Un <strong>contrôleur interne</strong> ne peut pas être engagé dans des décisions
|
||||||
|
* opérationnelles qu'il devra contrôler.
|
||||||
|
* <li>Un membre exclu/radié ne peut plus exercer aucun rôle.
|
||||||
|
* </ol>
|
||||||
|
*
|
||||||
|
* @since 2026-04-25
|
||||||
|
*/
|
||||||
|
@ApplicationScoped
|
||||||
|
public class SoDPermissionChecker {
|
||||||
|
|
||||||
|
private static final Logger LOG = Logger.getLogger(SoDPermissionChecker.class);
|
||||||
|
|
||||||
|
@Inject AuditTrailService auditTrail;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifie qu'un utilisateur peut valider une opération qu'un autre a créée.
|
||||||
|
*
|
||||||
|
* <p>Règle fondamentale : <strong>celui qui crée ne peut pas valider</strong> (4-eyes).
|
||||||
|
*
|
||||||
|
* @param creatorUserId UUID du créateur de l'opération (de la dépense, du paiement, etc.)
|
||||||
|
* @param validatorUserId UUID de l'utilisateur qui tente de valider
|
||||||
|
* @param entityType nom de l'entité (pour audit trail)
|
||||||
|
* @param entityId UUID de l'entité ciblée
|
||||||
|
* @return {@link SoDCheckResult#PASS} si la validation est autorisée ; sinon {@link
|
||||||
|
* SoDCheckResult#VIOLATION}
|
||||||
|
*/
|
||||||
|
public SoDCheckResult checkValidationDistinct(UUID creatorUserId, UUID validatorUserId,
|
||||||
|
String entityType, UUID entityId) {
|
||||||
|
if (creatorUserId == null || validatorUserId == null) {
|
||||||
|
return SoDCheckResult.PASS; // pas de contexte = on laisse passer (audit doit alerter)
|
||||||
|
}
|
||||||
|
if (creatorUserId.equals(validatorUserId)) {
|
||||||
|
String violation = String.format(
|
||||||
|
"SoD VIOLATION: même utilisateur (%s) a créé ET valide cette opération sur %s/%s",
|
||||||
|
validatorUserId, entityType, entityId);
|
||||||
|
LOG.warn(violation);
|
||||||
|
auditTrail.log(entityType, entityId, "SOD_OVERRIDE",
|
||||||
|
"Tentative de validation par le créateur",
|
||||||
|
null, null, null, false, violation);
|
||||||
|
return new SoDCheckResult(false, violation);
|
||||||
|
}
|
||||||
|
return SoDCheckResult.PASS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifie qu'un utilisateur n'a pas une combinaison de rôles incompatibles.
|
||||||
|
*
|
||||||
|
* <p>Combinaisons interdites par défaut :
|
||||||
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code TRESORIER} + {@code PRESIDENT} (cumul interdit pour engagements financiers)
|
||||||
|
* <li>{@code TRESORIER} + {@code CONTROLEUR_INTERNE} (auto-contrôle impossible)
|
||||||
|
* <li>{@code PRESIDENT} + {@code CONTROLEUR_INTERNE} (juge et partie)
|
||||||
|
* <li>{@code COMMISSAIRE_COMPTES} + tout autre rôle interne (indépendance OHADA)
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* @param userId UUID de l'utilisateur
|
||||||
|
* @param userRoles ensemble des rôles actuels de l'utilisateur dans l'organisation active
|
||||||
|
* @return résultat de la vérification
|
||||||
|
*/
|
||||||
|
public SoDCheckResult checkRoleCombination(UUID userId, Set<String> userRoles) {
|
||||||
|
if (userRoles == null || userRoles.size() <= 1) {
|
||||||
|
return SoDCheckResult.PASS;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Conflit Commissaire aux comptes (indépendance absolue OHADA)
|
||||||
|
if (userRoles.contains("COMMISSAIRE_COMPTES") && userRoles.size() > 1) {
|
||||||
|
String violation = "SoD: COMMISSAIRE_COMPTES doit être indépendant (aucun cumul) — user " + userId;
|
||||||
|
return new SoDCheckResult(false, violation);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Conflits trésorier
|
||||||
|
if (userRoles.contains("TRESORIER") && userRoles.contains("PRESIDENT")) {
|
||||||
|
String violation = "SoD: cumul TRESORIER + PRESIDENT interdit (engagement + ordonnancement) — user " + userId;
|
||||||
|
return new SoDCheckResult(false, violation);
|
||||||
|
}
|
||||||
|
if (userRoles.contains("TRESORIER") && userRoles.contains("CONTROLEUR_INTERNE")) {
|
||||||
|
String violation = "SoD: cumul TRESORIER + CONTROLEUR_INTERNE interdit (auto-contrôle) — user " + userId;
|
||||||
|
return new SoDCheckResult(false, violation);
|
||||||
|
}
|
||||||
|
if (userRoles.contains("PRESIDENT") && userRoles.contains("CONTROLEUR_INTERNE")) {
|
||||||
|
String violation = "SoD: cumul PRESIDENT + CONTROLEUR_INTERNE interdit (juge et partie) — user " + userId;
|
||||||
|
return new SoDCheckResult(false, violation);
|
||||||
|
}
|
||||||
|
|
||||||
|
return SoDCheckResult.PASS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifie que le Compliance Officer désigné n'est pas en conflit (Instruction BCEAO
|
||||||
|
* 001-03-2025 : rattaché DG, distinct du trésorier).
|
||||||
|
*/
|
||||||
|
public SoDCheckResult checkComplianceOfficerEligibility(UUID complianceOfficerId, Set<String> userRoles) {
|
||||||
|
if (complianceOfficerId == null || userRoles == null) {
|
||||||
|
return SoDCheckResult.PASS;
|
||||||
|
}
|
||||||
|
if (userRoles.contains("TRESORIER")) {
|
||||||
|
return new SoDCheckResult(false,
|
||||||
|
"SoD: Compliance Officer ne peut pas cumuler le rôle TRESORIER (Instruction BCEAO 001-03-2025)");
|
||||||
|
}
|
||||||
|
if (userRoles.contains("COMMISSAIRE_COMPTES")) {
|
||||||
|
return new SoDCheckResult(false,
|
||||||
|
"SoD: Compliance Officer ne peut pas cumuler COMMISSAIRE_COMPTES (indépendance)");
|
||||||
|
}
|
||||||
|
return SoDCheckResult.PASS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Résultat d'un check SoD : pass ou violation avec motif. */
|
||||||
|
public record SoDCheckResult(boolean passed, String violationReason) {
|
||||||
|
public static final SoDCheckResult PASS = new SoDCheckResult(true, null);
|
||||||
|
|
||||||
|
public static final SoDCheckResult VIOLATION =
|
||||||
|
new SoDCheckResult(false, "Violation SoD générique");
|
||||||
|
|
||||||
|
public boolean isViolation() {
|
||||||
|
return !passed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package dev.lions.unionflow.server.service;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seuils AML/LBC-FT applicables aux transactions UEMOA (BCEAO Instruction 002-03-2025 du 18 mars
|
||||||
|
* 2025).
|
||||||
|
*
|
||||||
|
* <p>Ces seuils déclenchent :
|
||||||
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li>Diligence renforcée KYC (vérification UBO, source des fonds, finalité)
|
||||||
|
* <li>Génération automatique d'une {@code AlerteAml} pour évaluation par le Compliance Officer
|
||||||
|
* <li>Potentiellement, une déclaration de soupçon (DOS) à la CENTIF si confirmation
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>Référence : <a
|
||||||
|
* href="https://www.bceao.int/sites/default/files/2025-04/Instruction%20n%C2%B0001-03-2025%20du%2018%20mars%2025%20portant%20modalit%C3%A9s%20de%20mise%20en%20oeuvre%20par%20les%20IF%20de%20leurs%20obligations%20en%20mati%C3%A8re%20de%20lutte%20contre%20le%20blanchiment%20de%20capitaux.pdf">Instruction
|
||||||
|
* BCEAO 001-03-2025</a>.
|
||||||
|
*
|
||||||
|
* @since 2026-04-25
|
||||||
|
*/
|
||||||
|
public final class AmlSeuils {
|
||||||
|
|
||||||
|
private AmlSeuils() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seuil intra-UEMOA (entre deux pays UMOA) au-delà duquel la transaction est soumise à
|
||||||
|
* surveillance et obligations de déclaration : <strong>10 000 000 FCFA</strong>.
|
||||||
|
*
|
||||||
|
* <p>Source : Instruction BCEAO 002-03-2025 du 18 mars 2025.
|
||||||
|
*/
|
||||||
|
public static final BigDecimal SEUIL_INTRA_UEMOA_FCFA = new BigDecimal("10000000");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seuil entrée/sortie territoire UEMOA (transaction transfrontalière hors UEMOA) déclenchant
|
||||||
|
* surveillance renforcée : <strong>5 000 000 FCFA</strong>.
|
||||||
|
*
|
||||||
|
* <p>Source : Instruction BCEAO 002-03-2025 du 18 mars 2025.
|
||||||
|
*/
|
||||||
|
public static final BigDecimal SEUIL_ENTREE_SORTIE_UEMOA_FCFA = new BigDecimal("5000000");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seuil unique par opération espèce (paiement direct cash) déclenchant identification
|
||||||
|
* renforcée : <strong>1 000 000 FCFA</strong>. Dérivé de la pratique GAFI (USD 15 000
|
||||||
|
* équivalent).
|
||||||
|
*/
|
||||||
|
public static final BigDecimal SEUIL_OPERATION_ESPECE_FCFA = new BigDecimal("1000000");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seuil cumulé sur 7 jours glissants pour détection de structuration (smurfing) :
|
||||||
|
* <strong>5 000 000 FCFA</strong> (8 fois le seuil unique). Configurable.
|
||||||
|
*/
|
||||||
|
public static final BigDecimal SEUIL_CUMUL_HEBDO_STRUCTURATION_FCFA = new BigDecimal("5000000");
|
||||||
|
|
||||||
|
/** Pays UEMOA (ISO 3166-1 alpha-3). */
|
||||||
|
public static final java.util.Set<String> PAYS_UEMOA = java.util.Set.of(
|
||||||
|
"BEN", // Bénin
|
||||||
|
"BFA", // Burkina Faso
|
||||||
|
"CIV", // Côte d'Ivoire
|
||||||
|
"GNB", // Guinée-Bissau
|
||||||
|
"MLI", // Mali
|
||||||
|
"NER", // Niger
|
||||||
|
"SEN", // Sénégal
|
||||||
|
"TGO" // Togo
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Détermine le seuil applicable à une transaction selon l'origine et la destination. */
|
||||||
|
public static BigDecimal seuilApplicable(String paysOrigine, String paysDestination) {
|
||||||
|
if (paysOrigine == null || paysDestination == null) {
|
||||||
|
return SEUIL_ENTREE_SORTIE_UEMOA_FCFA; // par défaut le plus restrictif
|
||||||
|
}
|
||||||
|
boolean origineUemoa = PAYS_UEMOA.contains(paysOrigine.toUpperCase());
|
||||||
|
boolean destinationUemoa = PAYS_UEMOA.contains(paysDestination.toUpperCase());
|
||||||
|
|
||||||
|
if (origineUemoa && destinationUemoa) {
|
||||||
|
return SEUIL_INTRA_UEMOA_FCFA;
|
||||||
|
}
|
||||||
|
return SEUIL_ENTREE_SORTIE_UEMOA_FCFA;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True si la transaction dépasse le seuil applicable. */
|
||||||
|
public static boolean depasseSeuil(BigDecimal montant, String paysOrigine, String paysDestination) {
|
||||||
|
if (montant == null) return false;
|
||||||
|
return montant.compareTo(seuilApplicable(paysOrigine, paysDestination)) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True si la transaction dépasse le seuil opération espèce. */
|
||||||
|
public static boolean depasseSeuilEspece(BigDecimal montant) {
|
||||||
|
if (montant == null) return false;
|
||||||
|
return montant.compareTo(SEUIL_OPERATION_ESPECE_FCFA) > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
-- ====================================================================
|
||||||
|
-- V43 — Sprint 1 P0 (consolidation 2026-04-25)
|
||||||
|
-- ====================================================================
|
||||||
|
-- Implémente plusieurs P0 de l'investigation métier :
|
||||||
|
-- - P0-NEW-9 : enum referentiel_comptable sur organisations (SYSCOHADA / SYCEBNL / PCSFD_UMOA)
|
||||||
|
-- - P0-NEW-14 : compliance_officer_id sur organisations (Instruction BCEAO 001-03-2025)
|
||||||
|
-- - P0-NEW-24 : numero_cmu sur utilisateurs (Loi 2014-131 CI, passage CMU obligatoire 1er jan 2026)
|
||||||
|
--
|
||||||
|
-- Voir : unionflow/docs/ETAT_PROJET_METIER_2026-04-25.md
|
||||||
|
-- ====================================================================
|
||||||
|
|
||||||
|
-- 1. Référentiel comptable sur organisations (P0-NEW-9)
|
||||||
|
ALTER TABLE organisations
|
||||||
|
ADD COLUMN IF NOT EXISTS referentiel_comptable VARCHAR(20) NOT NULL DEFAULT 'SYSCOHADA';
|
||||||
|
|
||||||
|
ALTER TABLE organisations DROP CONSTRAINT IF EXISTS organisations_referentiel_comptable_check;
|
||||||
|
ALTER TABLE organisations
|
||||||
|
ADD CONSTRAINT organisations_referentiel_comptable_check
|
||||||
|
CHECK (referentiel_comptable IN ('SYSCOHADA', 'SYCEBNL', 'PCSFD_UMOA'));
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_organisation_referentiel_comptable
|
||||||
|
ON organisations (referentiel_comptable);
|
||||||
|
|
||||||
|
-- Mapping initial des organisations existantes selon type_organisation
|
||||||
|
-- (les types non listés conservent SYSCOHADA par défaut)
|
||||||
|
UPDATE organisations
|
||||||
|
SET referentiel_comptable = 'SYCEBNL'
|
||||||
|
WHERE UPPER(type_organisation) IN (
|
||||||
|
'MUTUELLE_SANTE', 'ASSOCIATION', 'LIONS_CLUB', 'ONG', 'FONDATION',
|
||||||
|
'SYNDICAT', 'ORDRE_PROFESSIONNEL', 'PROJET_DEVELOPPEMENT'
|
||||||
|
);
|
||||||
|
|
||||||
|
UPDATE organisations
|
||||||
|
SET referentiel_comptable = 'PCSFD_UMOA'
|
||||||
|
WHERE UPPER(type_organisation) IN ('SFD_TIER_1', 'SFD_CATEGORIE_III');
|
||||||
|
|
||||||
|
COMMENT ON COLUMN organisations.referentiel_comptable IS
|
||||||
|
'Référentiel comptable applicable : SYSCOHADA (commercial/coopératif lucratif), '
|
||||||
|
'SYCEBNL (entités but non lucratif - Acte uniforme OHADA 1er jan 2024), '
|
||||||
|
'PCSFD_UMOA (SFD article 44 ≥ 2 Md FCFA encours).';
|
||||||
|
|
||||||
|
-- 2. Compliance officer sur organisations (P0-NEW-14)
|
||||||
|
-- Instruction BCEAO 001-03-2025 : Compliance officer rattaché DG, obligatoire LBC/FT
|
||||||
|
ALTER TABLE organisations
|
||||||
|
ADD COLUMN IF NOT EXISTS compliance_officer_id UUID;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_organisation_compliance_officer
|
||||||
|
ON organisations (compliance_officer_id) WHERE compliance_officer_id IS NOT NULL;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN organisations.compliance_officer_id IS
|
||||||
|
'UUID du membre désigné comme Compliance Officer (obligatoire selon Instruction BCEAO 001-03-2025). '
|
||||||
|
'Doit être rattaché à la direction générale, distinct du trésorier (séparation des pouvoirs).';
|
||||||
|
|
||||||
|
-- 3. numero_cmu sur utilisateurs (P0-NEW-24)
|
||||||
|
-- Loi 2014-131 instituant CMU CI ; passage à cotisation obligatoire le 1er janvier 2026.
|
||||||
|
-- Auto-déclaration côté membre car aucune API publique CNAM disponible au 2026-04-25.
|
||||||
|
ALTER TABLE utilisateurs
|
||||||
|
ADD COLUMN IF NOT EXISTS numero_cmu VARCHAR(11);
|
||||||
|
|
||||||
|
ALTER TABLE utilisateurs DROP CONSTRAINT IF EXISTS utilisateurs_numero_cmu_format_check;
|
||||||
|
ALTER TABLE utilisateurs
|
||||||
|
ADD CONSTRAINT utilisateurs_numero_cmu_format_check
|
||||||
|
CHECK (numero_cmu IS NULL OR numero_cmu ~ '^[A-Z0-9]{11}$');
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_utilisateur_numero_cmu
|
||||||
|
ON utilisateurs (numero_cmu) WHERE numero_cmu IS NOT NULL;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN utilisateurs.numero_cmu IS
|
||||||
|
'Numéro CMU (Couverture Maladie Universelle) CI auto-déclaré par le membre. '
|
||||||
|
'Format CNAM : 11 caractères alphanumériques majuscules. '
|
||||||
|
'Obligatoire pour les membres d''organisations de type MUTUELLE_SANTE (Loi 2014-131).';
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
-- ====================================================================
|
||||||
|
-- V44 — Sprint 1 P0 (consolidation 2026-04-25)
|
||||||
|
-- ====================================================================
|
||||||
|
-- P0-NEW-13 : Bénéficiaires effectifs (UBO — Ultimate Beneficial Owner)
|
||||||
|
--
|
||||||
|
-- Implémente l'obligation de l'Instruction BCEAO 003-03-2025 du 18 mars 2025 :
|
||||||
|
-- vérification systématique des bénéficiaires effectifs (UBO) lors du KYC,
|
||||||
|
-- selon une approche par les risques.
|
||||||
|
--
|
||||||
|
-- Critère UBO standard (GAFI / FATF) :
|
||||||
|
-- - Détention >= 25% capital ou droits de vote
|
||||||
|
-- - OU contrôle effectif (de fait / de droit)
|
||||||
|
-- - OU bénéficiaire ultime d'une opération suspecte
|
||||||
|
--
|
||||||
|
-- Conservation : 10 ans après clôture relation (directive 02/2015/CM/UEMOA).
|
||||||
|
-- ====================================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS beneficiaires_effectifs (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
-- Lien KYC
|
||||||
|
kyc_dossier_id UUID NOT NULL,
|
||||||
|
organisation_cible_id UUID,
|
||||||
|
membre_id UUID,
|
||||||
|
|
||||||
|
-- Identité
|
||||||
|
nom VARCHAR(100) NOT NULL,
|
||||||
|
prenoms VARCHAR(200) NOT NULL,
|
||||||
|
date_naissance DATE NOT NULL,
|
||||||
|
lieu_naissance VARCHAR(200),
|
||||||
|
nationalite VARCHAR(3) NOT NULL,
|
||||||
|
pays_residence VARCHAR(3),
|
||||||
|
|
||||||
|
-- Pièce d'identité
|
||||||
|
type_piece_identite VARCHAR(30),
|
||||||
|
numero_piece_identite VARCHAR(50),
|
||||||
|
date_expiration_piece DATE,
|
||||||
|
|
||||||
|
-- Contrôle
|
||||||
|
pourcentage_capital NUMERIC(5,2) CHECK (pourcentage_capital IS NULL OR (pourcentage_capital >= 0 AND pourcentage_capital <= 100)),
|
||||||
|
pourcentage_droits_vote NUMERIC(5,2) CHECK (pourcentage_droits_vote IS NULL OR (pourcentage_droits_vote >= 0 AND pourcentage_droits_vote <= 100)),
|
||||||
|
nature_controle VARCHAR(50) NOT NULL,
|
||||||
|
|
||||||
|
-- PEP
|
||||||
|
est_pep BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
pep_categorie VARCHAR(100),
|
||||||
|
pep_pays VARCHAR(3),
|
||||||
|
pep_fonction VARCHAR(200),
|
||||||
|
|
||||||
|
-- Sanctions
|
||||||
|
presence_listes_sanctions BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
details_listes_sanctions VARCHAR(1000),
|
||||||
|
|
||||||
|
-- Vérification
|
||||||
|
verifie_par_id UUID,
|
||||||
|
date_verification TIMESTAMP,
|
||||||
|
source_verification VARCHAR(200),
|
||||||
|
notes VARCHAR(2000),
|
||||||
|
|
||||||
|
-- BaseEntity audit
|
||||||
|
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,
|
||||||
|
|
||||||
|
CONSTRAINT fk_ubo_kyc_dossier FOREIGN KEY (kyc_dossier_id)
|
||||||
|
REFERENCES kyc_dossier(id) ON DELETE RESTRICT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ubo_kyc_dossier ON beneficiaires_effectifs (kyc_dossier_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ubo_organisation_cible ON beneficiaires_effectifs (organisation_cible_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ubo_membre ON beneficiaires_effectifs (membre_id) WHERE membre_id IS NOT NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ubo_pays ON beneficiaires_effectifs (pays_residence);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ubo_pep ON beneficiaires_effectifs (est_pep) WHERE est_pep = TRUE;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ubo_sanctions ON beneficiaires_effectifs (presence_listes_sanctions) WHERE presence_listes_sanctions = TRUE;
|
||||||
|
|
||||||
|
COMMENT ON TABLE beneficiaires_effectifs IS
|
||||||
|
'Bénéficiaires effectifs (UBO) liés aux dossiers KYC. '
|
||||||
|
'Implémente Instruction BCEAO 003-03-2025 (KYC + UBO). '
|
||||||
|
'Conservation 10 ans après clôture relation (directive 02/2015/CM/UEMOA).';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN beneficiaires_effectifs.nature_controle IS
|
||||||
|
'DETENTION_CAPITAL, DROITS_VOTE, CONTROLE_DE_FAIT, BENEFICIAIRE_ULTIME, MANDAT_REPRESENTATION';
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
-- ====================================================================
|
||||||
|
-- V45 — Sprint 1 P0 (consolidation 2026-04-25)
|
||||||
|
-- ====================================================================
|
||||||
|
-- P0-NEW-17 : Rôle PRESIDENT (distinct d'ADMIN_ORGANISATION)
|
||||||
|
-- P0-NEW-18 : Rôle CONTROLEUR_INTERNE (BCEAO Circulaire 03-2017/CB/C)
|
||||||
|
-- P0-NEW-19 : Audit trail enrichi (table audit_trail_operations)
|
||||||
|
-- ====================================================================
|
||||||
|
|
||||||
|
-- 1. Rôles standards manquants pour gouvernance OHADA / SoD
|
||||||
|
-- Insertion dans roles existant (V13__Seed_Standard_Roles.sql avait posé la base)
|
||||||
|
INSERT INTO roles (id, nom, description, categorie, niveau_hierarchie, actif, cree_le, version)
|
||||||
|
VALUES
|
||||||
|
(gen_random_uuid(), 'PRESIDENT',
|
||||||
|
'Président de l''organisation : représentant légal, signataire PV AG/CA, convoque les instances. Distinct d''ADMIN_ORGANISATION (rôle technique).',
|
||||||
|
'GOUVERNANCE', 1, TRUE, CURRENT_TIMESTAMP, 0),
|
||||||
|
(gen_random_uuid(), 'VICE_PRESIDENT',
|
||||||
|
'Vice-président : suppléance statutaire du président (OHADA AUDSCGIE).',
|
||||||
|
'GOUVERNANCE', 2, TRUE, CURRENT_TIMESTAMP, 0),
|
||||||
|
(gen_random_uuid(), 'CONTROLEUR_INTERNE',
|
||||||
|
'Contrôleur interne : obligatoire SFD UMOA (BCEAO Circulaire 03-2017/CB/C). Supervise risques, conformité, audit interne.',
|
||||||
|
'CONTROLE', 3, TRUE, CURRENT_TIMESTAMP, 0),
|
||||||
|
(gen_random_uuid(), 'ANIMATEUR_ZONE',
|
||||||
|
'Animateur de zone / délégué régional : enquête sociale demandes d''aide, lien terrain.',
|
||||||
|
'OPERATIONNEL', 4, TRUE, CURRENT_TIMESTAMP, 0),
|
||||||
|
(gen_random_uuid(), 'SECRETAIRE_ADJOINT',
|
||||||
|
'Secrétaire adjoint : suppléance secrétaire général.',
|
||||||
|
'GOUVERNANCE', 5, TRUE, CURRENT_TIMESTAMP, 0),
|
||||||
|
(gen_random_uuid(), 'TRESORIER_ADJOINT',
|
||||||
|
'Trésorier adjoint : suppléance trésorier (séparation des pouvoirs maintenue).',
|
||||||
|
'GOUVERNANCE', 5, TRUE, CURRENT_TIMESTAMP, 0)
|
||||||
|
ON CONFLICT (nom) DO NOTHING;
|
||||||
|
|
||||||
|
-- 2. Audit trail enrichi (P0-NEW-19) — SYSCOHADA + AUDSCGIE OHADA
|
||||||
|
CREATE TABLE IF NOT EXISTS audit_trail_operations (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
-- Acteur
|
||||||
|
user_id UUID NOT NULL,
|
||||||
|
user_email VARCHAR(255),
|
||||||
|
role_actif VARCHAR(50), -- valeur du header X-Active-Role au moment de l'action
|
||||||
|
organisation_active_id UUID, -- valeur du header X-Active-Organisation-Id
|
||||||
|
|
||||||
|
-- Action
|
||||||
|
action_type VARCHAR(50) NOT NULL, -- CREATE, UPDATE, DELETE, APPROVE, REJECT, EXPORT, LOGIN, LOGOUT, SoD_OVERRIDE
|
||||||
|
entity_type VARCHAR(100) NOT NULL, -- ex: Cotisation, DemandeAide, Membre, EcritureComptable
|
||||||
|
entity_id UUID, -- UUID de l'entité ciblée (null pour actions globales)
|
||||||
|
description VARCHAR(500),
|
||||||
|
|
||||||
|
-- Contexte
|
||||||
|
ip_address VARCHAR(45), -- IPv4 ou IPv6
|
||||||
|
user_agent VARCHAR(500),
|
||||||
|
request_id UUID, -- corrélation avec logs HTTP
|
||||||
|
|
||||||
|
-- Données
|
||||||
|
payload_avant JSONB, -- snapshot avant (UPDATE/DELETE)
|
||||||
|
payload_apres JSONB, -- snapshot après (CREATE/UPDATE)
|
||||||
|
metadata JSONB, -- métadonnées libres (ex: motif rejet, montant, etc.)
|
||||||
|
|
||||||
|
-- Vérifications de sécurité (SoD)
|
||||||
|
sod_check_passed BOOLEAN,
|
||||||
|
sod_violations VARCHAR(500),
|
||||||
|
|
||||||
|
-- Timestamps
|
||||||
|
operation_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT chk_audit_action_type CHECK (action_type IN (
|
||||||
|
'CREATE', 'UPDATE', 'DELETE', 'APPROVE', 'REJECT', 'EXPORT',
|
||||||
|
'LOGIN', 'LOGOUT', 'SOD_OVERRIDE', 'KYC_VALIDE', 'KYC_REFUSE',
|
||||||
|
'PAYMENT_INITIATED', 'PAYMENT_CONFIRMED', 'PAYMENT_FAILED',
|
||||||
|
'BUDGET_APPROVED', 'AID_REQUEST_APPROVED', 'CONFIG_CHANGED'
|
||||||
|
))
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Index pour recherches fréquentes
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_trail_user ON audit_trail_operations (user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_trail_org_active ON audit_trail_operations (organisation_active_id) WHERE organisation_active_id IS NOT NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_trail_entity ON audit_trail_operations (entity_type, entity_id) WHERE entity_id IS NOT NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_trail_action ON audit_trail_operations (action_type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_trail_operation_at ON audit_trail_operations (operation_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_trail_sod_violations ON audit_trail_operations (sod_check_passed) WHERE sod_check_passed = FALSE;
|
||||||
|
|
||||||
|
COMMENT ON TABLE audit_trail_operations IS
|
||||||
|
'Audit trail enrichi conforme SYSCOHADA + AUDSCGIE OHADA. '
|
||||||
|
'Trace toutes les opérations financières, lifecycle membres, et changements de configuration. '
|
||||||
|
'Inclut le contexte multi-org (rôle actif + organisation active) + vérifications SoD. '
|
||||||
|
'Conservation : durée légale OHADA (10 ans minimum pour pièces comptables).';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN audit_trail_operations.sod_check_passed IS
|
||||||
|
'Indique si le check Separation of Duties a passé pour cette opération. '
|
||||||
|
'FALSE = violation SoD détectée (ex: même user a validé création + paiement).';
|
||||||
Reference in New Issue
Block a user