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

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:
2026-04-25 01:15:25 +00:00
parent 6e9841b3bb
commit d8006c8425
14 changed files with 1096 additions and 0 deletions

View File

@@ -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();
}

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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 */

View File

@@ -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;
};
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}
}

View File

@@ -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;
} }

View File

@@ -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;
}
}
}

View File

@@ -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;
}
}

View File

@@ -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).';

View File

@@ -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';

View File

@@ -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).';