diff --git a/src/main/java/dev/lions/unionflow/server/entity/AuditTrailOperation.java b/src/main/java/dev/lions/unionflow/server/entity/AuditTrailOperation.java new file mode 100644 index 0000000..ddea524 --- /dev/null +++ b/src/main/java/dev/lions/unionflow/server/entity/AuditTrailOperation.java @@ -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). + * + *

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). + * + *

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(); +} diff --git a/src/main/java/dev/lions/unionflow/server/entity/BeneficiaireEffectif.java b/src/main/java/dev/lions/unionflow/server/entity/BeneficiaireEffectif.java new file mode 100644 index 0000000..7414bd7 --- /dev/null +++ b/src/main/java/dev/lions/unionflow/server/entity/BeneficiaireEffectif.java @@ -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. + * + *

Implémente l'obligation introduite par l'Instruction BCEAO 003-03-2025 du 18 mars + * 2025 : 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). + * + *

Un bénéficiaire effectif est, selon la directive UEMOA et le GAFI/FATF, toute personne + * physique qui : + * + *

+ * + *

Ces enregistrements doivent être conservés 10 ans 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; +} diff --git a/src/main/java/dev/lions/unionflow/server/entity/Membre.java b/src/main/java/dev/lions/unionflow/server/entity/Membre.java index a8e0565..bd83e69 100644 --- a/src/main/java/dev/lions/unionflow/server/entity/Membre.java +++ b/src/main/java/dev/lions/unionflow/server/entity/Membre.java @@ -63,6 +63,20 @@ public class Membre extends BaseEntity { @Column(name = "fcm_token", length = 500) private String fcmToken; + /** + * Numéro CMU (Couverture Maladie Universelle) Côte d'Ivoire — auto-déclaré par le membre. + * + *

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)") @Column(name = "telephone_wave", length = 20) private String telephoneWave; diff --git a/src/main/java/dev/lions/unionflow/server/entity/Organisation.java b/src/main/java/dev/lions/unionflow/server/entity/Organisation.java index eeb84ac..cd1b96b 100644 --- a/src/main/java/dev/lions/unionflow/server/entity/Organisation.java +++ b/src/main/java/dev/lions/unionflow/server/entity/Organisation.java @@ -210,6 +210,30 @@ public class Organisation extends BaseEntity { @Column(name = "modules_actifs", length = 1000) private String modulesActifs; + /** + * Référentiel comptable applicable à cette organisation. + * + *

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 /** Adhésions des membres à cette organisation */ diff --git a/src/main/java/dev/lions/unionflow/server/entity/ReferentielComptable.java b/src/main/java/dev/lions/unionflow/server/entity/ReferentielComptable.java new file mode 100644 index 0000000..cee5a18 --- /dev/null +++ b/src/main/java/dev/lions/unionflow/server/entity/ReferentielComptable.java @@ -0,0 +1,68 @@ +package dev.lions.unionflow.server.entity; + +/** + * Référentiel comptable applicable à une {@link Organisation}. + * + *

OHADA dispose désormais de plusieurs référentiels selon la nature de l'entité : + * + *

+ * + *

Le mapping par défaut depuis {@code Organisation.typeOrganisation} se trouve dans + * {@link #defaultFor(String)}. L'admin peut overrider manuellement (cas hybrides). + * + *

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; + }; + } +} diff --git a/src/main/java/dev/lions/unionflow/server/repository/AuditTrailOperationRepository.java b/src/main/java/dev/lions/unionflow/server/repository/AuditTrailOperationRepository.java new file mode 100644 index 0000000..52ce672 --- /dev/null +++ b/src/main/java/dev/lions/unionflow/server/repository/AuditTrailOperationRepository.java @@ -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 { + + /** Opérations d'un utilisateur dans une fenêtre temporelle. */ + public List 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 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 findByOrganisationActive(UUID organisationActiveId) { + return list("organisationActiveId = ?1 ORDER BY operationAt DESC", organisationActiveId); + } + + /** Violations SoD détectées (alertes compliance officer). */ + public List findSoDViolations() { + return list("sodCheckPassed = false ORDER BY operationAt DESC"); + } + + /** Opérations financières (paiements, budgets, écritures comptables) pour reporting AIRMS. */ + public List 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); + } +} diff --git a/src/main/java/dev/lions/unionflow/server/repository/BeneficiaireEffectifRepository.java b/src/main/java/dev/lions/unionflow/server/repository/BeneficiaireEffectifRepository.java new file mode 100644 index 0000000..55c7090 --- /dev/null +++ b/src/main/java/dev/lions/unionflow/server/repository/BeneficiaireEffectifRepository.java @@ -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 { + + /** Bénéficiaires effectifs liés à un dossier KYC. */ + public List 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 findByOrganisationCible(UUID organisationCibleId) { + return list("organisationCibleId", organisationCibleId); + } + + /** UBOs identifiés comme PEP. */ + public List findPep() { + return list("estPep", true); + } + + /** UBOs présents sur des listes de sanctions. */ + public List findOnSanctionsLists() { + return list("presenceListesSanctions", true); + } +} diff --git a/src/main/java/dev/lions/unionflow/server/security/AuditTrailService.java b/src/main/java/dev/lions/unionflow/server/security/AuditTrailService.java new file mode 100644 index 0000000..55e3f7b --- /dev/null +++ b/src/main/java/dev/lions/unionflow/server/security/AuditTrailService.java @@ -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). + * + *

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). + * + *

Usage typique dans un service métier : + * + *

{@code
+ *   @Inject AuditTrailService auditTrail;
+ *
+ *   public Cotisation enregistrerPaiement(...) {
+ *       Cotisation c = ...
+ *       auditTrail.log("Cotisation", c.getId(), "PAYMENT_CONFIRMED",
+ *                      "Paiement confirmé via " + provider, c);
+ *       return c;
+ *   }
+ * }
+ * + * @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; + } + } +} diff --git a/src/main/java/dev/lions/unionflow/server/security/OrganisationContextHolder.java b/src/main/java/dev/lions/unionflow/server/security/OrganisationContextHolder.java index 6061d39..7c537d9 100644 --- a/src/main/java/dev/lions/unionflow/server/security/OrganisationContextHolder.java +++ b/src/main/java/dev/lions/unionflow/server/security/OrganisationContextHolder.java @@ -28,6 +28,39 @@ public class OrganisationContextHolder { private Organisation organisation; 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() { return organisationId; } diff --git a/src/main/java/dev/lions/unionflow/server/security/SoDPermissionChecker.java b/src/main/java/dev/lions/unionflow/server/security/SoDPermissionChecker.java new file mode 100644 index 0000000..bd2d7803 --- /dev/null +++ b/src/main/java/dev/lions/unionflow/server/security/SoDPermissionChecker.java @@ -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). + * + *

Implémente les règles SoD exigées par : + * + *

+ * + *

Les règles principales : + * + *

    + *
  1. Un même utilisateur ne peut pas créer une dépense ET la valider (4-eyes + * principle pour engagement → ordonnancement → paiement). + *
  2. Le Compliance Officer ne peut pas être simultanément trésorier ou + * président. + *
  3. Un contrôleur interne ne peut pas être engagé dans des décisions + * opérationnelles qu'il devra contrôler. + *
  4. Un membre exclu/radié ne peut plus exercer aucun rôle. + *
+ * + * @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. + * + *

Règle fondamentale : celui qui crée ne peut pas valider (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. + * + *

Combinaisons interdites par défaut : + * + *

+ * + * @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 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 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; + } + } +} diff --git a/src/main/java/dev/lions/unionflow/server/service/AmlSeuils.java b/src/main/java/dev/lions/unionflow/server/service/AmlSeuils.java new file mode 100644 index 0000000..75c2b2a --- /dev/null +++ b/src/main/java/dev/lions/unionflow/server/service/AmlSeuils.java @@ -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). + * + *

Ces seuils déclenchent : + * + *

+ * + *

Référence : Instruction + * BCEAO 001-03-2025. + * + * @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 : 10 000 000 FCFA. + * + *

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 : 5 000 000 FCFA. + * + *

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 : 1 000 000 FCFA. 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) : + * 5 000 000 FCFA (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 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; + } +} diff --git a/src/main/resources/db/migration/V43__P0_2026_04_25_Multi_Referentiel_CMU_Compliance_Officer.sql b/src/main/resources/db/migration/V43__P0_2026_04_25_Multi_Referentiel_CMU_Compliance_Officer.sql new file mode 100644 index 0000000..3cf54d0 --- /dev/null +++ b/src/main/resources/db/migration/V43__P0_2026_04_25_Multi_Referentiel_CMU_Compliance_Officer.sql @@ -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).'; diff --git a/src/main/resources/db/migration/V44__P0_2026_04_25_Beneficiaires_Effectifs_UBO.sql b/src/main/resources/db/migration/V44__P0_2026_04_25_Beneficiaires_Effectifs_UBO.sql new file mode 100644 index 0000000..43eabd4 --- /dev/null +++ b/src/main/resources/db/migration/V44__P0_2026_04_25_Beneficiaires_Effectifs_UBO.sql @@ -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'; diff --git a/src/main/resources/db/migration/V45__P0_2026_04_25_Roles_President_Controleur_Audit_Trail.sql b/src/main/resources/db/migration/V45__P0_2026_04_25_Roles_President_Controleur_Audit_Trail.sql new file mode 100644 index 0000000..e527fa1 --- /dev/null +++ b/src/main/resources/db/migration/V45__P0_2026_04_25_Roles_President_Controleur_Audit_Trail.sql @@ -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).';