From 86842f27af93e3e478923e6158ef7b574b17c059 Mon Sep 17 00:00:00 2001
From: dahoud <41957584+DahoudG@users.noreply.github.com>
Date: Sat, 25 Apr 2026 10:33:05 +0000
Subject: [PATCH] =?UTF-8?q?feat(sprint-6=20P2-NEW-7=202026-04-25):=20multi?=
=?UTF-8?q?-devise=20+=20KYC=20non-r=C3=A9sident=20diaspora=20+=20tests?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Ouvre UnionFlow à la diaspora UEMOA (France, USA, Canada, UK, Suisse...).
Entités & migration
- Enum Devise (10 valeurs : XOF, XAF, EUR, USD, GBP, CAD, CHF, GHS, NGN, MAD)
- Zones : UEMOA, CEMAC, CEDEAO, EUROPE, AMERIQUE, MAGHREB
- DEVISES_INTERNATIONALES : EUR/USD/GBP/CAD/CHF (déclenchent AML international)
- Entité TauxChange (devise_source × devise_cible × date_validite, taux NUMERIC(18,8))
- Repository : trouverExact, trouverPlusRecent (≤ date)
- V49 :
- Table taux_change (contrainte unicité paire+date, devises distinctes)
- Seed BCEAO_FIXED EUR↔XOF + taux indicatifs USD/GBP/CAD au 2026-04-25
- Membres : pays_residence (ISO-3), numero_passeport, numero_fiscal_etranger, est_diaspora, devise_preferee
- KycDossiers : pays_origine_fonds, justificatif_residence_etrangere, niveau_due_diligence (SIMPLIFIE/STANDARD/RENFORCE)
DeviseConversionService
- Stratégie de résolution : direct → inverse → pivot via XOF → fallback récent ≤ date
- Cache thread-safe (ConcurrentHashMap, TTL 1h)
- TauxIntrouvableException si aucun taux résolvable
- invaliderCache() pour reload après import batch
KycDiasporaService
- validerCoherence : passeport obligatoire si diaspora, pays_residence ≠ UEMOA, format passeport regex
- determinerNiveauDueDiligence (Instr. BCEAO 001-03-2025) :
- PEP → RENFORCE
- Diaspora pays sécurisés (UE/G7/Asie) → STANDARD
- Diaspora FATF grey-list → RENFORCE
- Diaspora pays inconnu → RENFORCE par prudence
- depasseSeuilAmlInternational : seuil 1000 EUR équivalent, false sur devises locales
- PAYS_UEMOA hardcodé (8 pays), PAYS_GREY_LIST_FATF snapshot 2026-04-25
Tests Sprint 6 (34/34 verts)
- DeviseTest : 5 tests (référence, internationales, zones, libellés)
- DeviseConversionServiceTest : 10 tests (identité, direct, inverse, pivot XOF, fallback récent, cache, invalider, exception, inputs invalides)
- KycDiasporaServiceTest : 19 tests (cohérence valide/sans passeport/pays UEMOA/pays étranger, due diligence PEP/FRA/grey-list/inconnu/UEMOA, seuil EUR/USD avec taux/sans taux/XOF/null)
---
.../lions/unionflow/server/entity/Devise.java | 58 ++++++
.../unionflow/server/entity/KycDossier.java | 23 +++
.../lions/unionflow/server/entity/Membre.java | 28 +++
.../unionflow/server/entity/TauxChange.java | 62 ++++++
.../repository/TauxChangeRepository.java | 27 +++
.../devise/DeviseConversionService.java | 156 +++++++++++++++
.../service/diaspora/KycDiasporaService.java | 153 +++++++++++++++
...9__P2_2026_04_25_Multi_Devise_Diaspora.sql | 84 ++++++++
.../unionflow/server/entity/DeviseTest.java | 51 +++++
.../devise/DeviseConversionServiceTest.java | 181 ++++++++++++++++++
.../diaspora/KycDiasporaServiceTest.java | 181 ++++++++++++++++++
11 files changed, 1004 insertions(+)
create mode 100644 src/main/java/dev/lions/unionflow/server/entity/Devise.java
create mode 100644 src/main/java/dev/lions/unionflow/server/entity/TauxChange.java
create mode 100644 src/main/java/dev/lions/unionflow/server/repository/TauxChangeRepository.java
create mode 100644 src/main/java/dev/lions/unionflow/server/service/devise/DeviseConversionService.java
create mode 100644 src/main/java/dev/lions/unionflow/server/service/diaspora/KycDiasporaService.java
create mode 100644 src/main/resources/db/migration/V49__P2_2026_04_25_Multi_Devise_Diaspora.sql
create mode 100644 src/test/java/dev/lions/unionflow/server/entity/DeviseTest.java
create mode 100644 src/test/java/dev/lions/unionflow/server/service/devise/DeviseConversionServiceTest.java
create mode 100644 src/test/java/dev/lions/unionflow/server/service/diaspora/KycDiasporaServiceTest.java
diff --git a/src/main/java/dev/lions/unionflow/server/entity/Devise.java b/src/main/java/dev/lions/unionflow/server/entity/Devise.java
new file mode 100644
index 0000000..25cbedc
--- /dev/null
+++ b/src/main/java/dev/lions/unionflow/server/entity/Devise.java
@@ -0,0 +1,58 @@
+package dev.lions.unionflow.server.entity;
+
+import java.util.Set;
+
+/**
+ * Devises supportées par UnionFlow.
+ *
+ *
UnionFlow vise prioritairement la zone UEMOA (XOF/XAF) mais s'ouvre à la diaspora
+ * (EUR/USD/GBP/CAD). Le {@link ZoneDevise} permet de discriminer pour les règles
+ * AML (transferts internationaux, due diligence renforcée).
+ *
+ * @since 2026-04-25 (P2-NEW-7)
+ */
+public enum Devise {
+
+ // Zone UEMOA / CEMAC
+ XOF("Franc CFA Ouest", ZoneDevise.UEMOA),
+ XAF("Franc CFA Centrale", ZoneDevise.CEMAC),
+
+ // Diaspora — Europe / Amérique
+ EUR("Euro", ZoneDevise.EUROPE),
+ USD("Dollar US", ZoneDevise.AMERIQUE),
+ GBP("Livre Sterling", ZoneDevise.EUROPE),
+ CAD("Dollar Canadien", ZoneDevise.AMERIQUE),
+ CHF("Franc Suisse", ZoneDevise.EUROPE),
+
+ // CEDEAO non-UEMOA (pour intégrations futures)
+ GHS("Cédi Ghanéen", ZoneDevise.CEDEAO),
+ NGN("Naira Nigérian", ZoneDevise.CEDEAO),
+
+ // Maghreb
+ MAD("Dirham Marocain", ZoneDevise.MAGHREB);
+
+ private final String libelle;
+ private final ZoneDevise zone;
+
+ Devise(String libelle, ZoneDevise zone) {
+ this.libelle = libelle;
+ this.zone = zone;
+ }
+
+ public String libelle() { return libelle; }
+ public ZoneDevise zone() { return zone; }
+
+ /** Devise de référence UnionFlow / BCEAO. */
+ public static Devise reference() { return XOF; }
+
+ /** Devises pour lesquelles un transfert depuis/vers UEMOA déclenche AML renforcé. */
+ public static final Set DEVISES_INTERNATIONALES = Set.of(EUR, USD, GBP, CAD, CHF);
+
+ public boolean estInternationale() {
+ return DEVISES_INTERNATIONALES.contains(this);
+ }
+
+ public enum ZoneDevise {
+ UEMOA, CEMAC, CEDEAO, EUROPE, AMERIQUE, MAGHREB
+ }
+}
diff --git a/src/main/java/dev/lions/unionflow/server/entity/KycDossier.java b/src/main/java/dev/lions/unionflow/server/entity/KycDossier.java
index 00799e1..f969c2e 100644
--- a/src/main/java/dev/lions/unionflow/server/entity/KycDossier.java
+++ b/src/main/java/dev/lions/unionflow/server/entity/KycDossier.java
@@ -106,6 +106,29 @@ public class KycDossier extends BaseEntity {
@Builder.Default
private int anneeReference = java.time.LocalDate.now().getYear();
+ /** Pays d'origine des fonds (ISO-3) — anti-blanchiment transferts internationaux. */
+ @Size(max = 3)
+ @Column(name = "pays_origine_fonds", length = 3)
+ private String paysOrigineFonds;
+
+ /** URL/chemin justificatif domicile étranger (facture EDF/British Gas/etc.) pour non-résidents. */
+ @Size(max = 500)
+ @Column(name = "justificatif_residence_etrangere", length = 500)
+ private String justificatifResidenceEtrangere;
+
+ /**
+ * Niveau de due diligence (Instr. BCEAO 001-03-2025) :
+ *
+ * - SIMPLIFIE — risque faible, opérations limitées
+ * - STANDARD — défaut
+ * - RENFORCE — non-résidents, PEP, FATF grey-list
+ *
+ */
+ @Size(max = 20)
+ @Column(name = "niveau_due_diligence", nullable = false, length = 20)
+ @Builder.Default
+ private String niveauDueDiligence = "STANDARD";
+
public boolean isPieceExpiree() {
return dateExpirationPiece != null && dateExpirationPiece.isBefore(LocalDate.now());
}
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 bd83e69..d78ba0b 100644
--- a/src/main/java/dev/lions/unionflow/server/entity/Membre.java
+++ b/src/main/java/dev/lions/unionflow/server/entity/Membre.java
@@ -77,6 +77,34 @@ public class Membre extends BaseEntity {
@Column(name = "numero_cmu", length = 11)
private String numeroCMU;
+ /**
+ * Pays de résidence (ISO-3, ex: FRA, USA, CAN). Différent de {@code nationalite} :
+ * un Ivoirien (CIV) résidant en France a paysResidence=FRA. NULL ou CIV = résident UEMOA.
+ *
+ * @since 2026-04-25 (P2-NEW-7)
+ */
+ @Pattern(regexp = "^[A-Z]{3}$|^$", message = "Pays résidence doit être un code ISO-3")
+ @Column(name = "pays_residence", length = 3)
+ private String paysResidence;
+
+ /** Numéro de passeport pour non-résidents (CNI insuffisante hors UEMOA). */
+ @Column(name = "numero_passeport", length = 50)
+ private String numeroPasseport;
+
+ /** NIF/SSN/SIN — reporting fiscal accord bilatéral CI ↔ pays résidence. */
+ @Column(name = "numero_fiscal_etranger", length = 50)
+ private String numeroFiscalEtranger;
+
+ /** TRUE si le membre est diaspora (résidence ≠ UEMOA). */
+ @Builder.Default
+ @Column(name = "est_diaspora", nullable = false)
+ private Boolean estDiaspora = false;
+
+ /** Devise préférée pour affichages et notifications (XOF par défaut). */
+ @Builder.Default
+ @Column(name = "devise_preferee", nullable = false, length = 3)
+ private String devisePreferee = "XOF";
+
@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/TauxChange.java b/src/main/java/dev/lions/unionflow/server/entity/TauxChange.java
new file mode 100644
index 0000000..507939e
--- /dev/null
+++ b/src/main/java/dev/lions/unionflow/server/entity/TauxChange.java
@@ -0,0 +1,62 @@
+package dev.lions.unionflow.server.entity;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.EnumType;
+import jakarta.persistence.Enumerated;
+import jakarta.persistence.Index;
+import jakarta.persistence.Table;
+import jakarta.persistence.UniqueConstraint;
+import jakarta.validation.constraints.DecimalMin;
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.NoArgsConstructor;
+
+/**
+ * Taux de change quotidien entre deux {@link Devise}s.
+ *
+ * Source : BCEAO (officiel UEMOA), ECB, Fixer.io ou import manuel. Conservation
+ * historique pour audit et conversions rétroactives.
+ *
+ * @since 2026-04-25 (P2-NEW-7)
+ */
+@Entity
+@Table(name = "taux_change",
+ uniqueConstraints = @UniqueConstraint(
+ name = "uq_taux_change_paire_date",
+ columnNames = {"devise_source", "devise_cible", "date_validite"}),
+ indexes = {
+ @Index(name = "idx_taux_change_paire", columnList = "devise_source,devise_cible"),
+ @Index(name = "idx_taux_change_date_validite", columnList = "date_validite")
+ })
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+@EqualsAndHashCode(callSuper = true)
+public class TauxChange extends BaseEntity {
+
+ @Enumerated(EnumType.STRING)
+ @Column(name = "devise_source", nullable = false, length = 3)
+ private Devise deviseSource;
+
+ @Enumerated(EnumType.STRING)
+ @Column(name = "devise_cible", nullable = false, length = 3)
+ private Devise deviseCible;
+
+ /** 1 unité de {@code deviseSource} = {@code taux} unités de {@code deviseCible}. */
+ @DecimalMin(value = "0.00000001", inclusive = false)
+ @Column(name = "taux", nullable = false, precision = 18, scale = 8)
+ private BigDecimal taux;
+
+ @Column(name = "date_validite", nullable = false)
+ private LocalDate dateValidite;
+
+ @Builder.Default
+ @Column(name = "source", nullable = false, length = 50)
+ private String source = "BCEAO";
+}
diff --git a/src/main/java/dev/lions/unionflow/server/repository/TauxChangeRepository.java b/src/main/java/dev/lions/unionflow/server/repository/TauxChangeRepository.java
new file mode 100644
index 0000000..40e9333
--- /dev/null
+++ b/src/main/java/dev/lions/unionflow/server/repository/TauxChangeRepository.java
@@ -0,0 +1,27 @@
+package dev.lions.unionflow.server.repository;
+
+import dev.lions.unionflow.server.entity.Devise;
+import dev.lions.unionflow.server.entity.TauxChange;
+import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase;
+import jakarta.enterprise.context.ApplicationScoped;
+import java.time.LocalDate;
+import java.util.Optional;
+import java.util.UUID;
+
+/** Repository des taux de change historisés. */
+@ApplicationScoped
+public class TauxChangeRepository implements PanacheRepositoryBase {
+
+ /** Taux exact pour une paire à une date donnée. */
+ public Optional trouverExact(Devise source, Devise cible, LocalDate date) {
+ return find("deviseSource = ?1 AND deviseCible = ?2 AND dateValidite = ?3",
+ source, cible, date).firstResultOptional();
+ }
+
+ /** Taux le plus récent pour une paire (≤ date donnée). */
+ public Optional trouverPlusRecent(Devise source, Devise cible, LocalDate dateMax) {
+ return find("deviseSource = ?1 AND deviseCible = ?2 AND dateValidite <= ?3 "
+ + "ORDER BY dateValidite DESC", source, cible, dateMax)
+ .firstResultOptional();
+ }
+}
diff --git a/src/main/java/dev/lions/unionflow/server/service/devise/DeviseConversionService.java b/src/main/java/dev/lions/unionflow/server/service/devise/DeviseConversionService.java
new file mode 100644
index 0000000..1e0827a
--- /dev/null
+++ b/src/main/java/dev/lions/unionflow/server/service/devise/DeviseConversionService.java
@@ -0,0 +1,156 @@
+package dev.lions.unionflow.server.service.devise;
+
+import dev.lions.unionflow.server.entity.Devise;
+import dev.lions.unionflow.server.entity.TauxChange;
+import dev.lions.unionflow.server.repository.TauxChangeRepository;
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.inject.Inject;
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.LocalDate;
+import java.util.Optional;
+import java.util.concurrent.ConcurrentHashMap;
+import org.jboss.logging.Logger;
+
+/**
+ * Service de conversion entre devises basé sur la table {@code taux_change}.
+ *
+ * Stratégie de résolution :
+ *
+ * - Cas trivial : devises identiques → identité
+ * - Lookup taux exact (devise_source, devise_cible, date) — cache 1h
+ * - Lookup taux exact inverse → 1/taux
+ * - Pivot via XOF (chaîne) : source → XOF → cible
+ * - Fallback : taux le plus récent ≤ date
+ * - Sinon : exception
+ *
+ *
+ * Pas d'appel HTTP automatique ici — la mise à jour des taux est faite par un job batch
+ * dédié (à venir). Le seed V49 fournit les paires principales.
+ *
+ * @since 2026-04-25 (P2-NEW-7)
+ */
+@ApplicationScoped
+public class DeviseConversionService {
+
+ private static final Logger LOG = Logger.getLogger(DeviseConversionService.class);
+ private static final Duration CACHE_TTL = Duration.ofHours(1);
+ private static final int SCALE_RESULTAT = 4;
+
+ @Inject TauxChangeRepository repository;
+
+ /** Cache thread-safe : (source, cible, date) → (taux, timestamp). */
+ private final ConcurrentHashMap cache = new ConcurrentHashMap<>();
+
+ /** Convertit {@code montant} de {@code source} vers {@code cible} à la date donnée. */
+ public BigDecimal convertir(BigDecimal montant, Devise source, Devise cible, LocalDate date) {
+ if (montant == null) throw new IllegalArgumentException("montant null");
+ if (source == null || cible == null) throw new IllegalArgumentException("devise null");
+ if (source == cible) return montant;
+
+ BigDecimal taux = trouverTaux(source, cible, date);
+ return montant.multiply(taux).setScale(SCALE_RESULTAT, RoundingMode.HALF_UP);
+ }
+
+ /** Convertit à la date du jour. */
+ public BigDecimal convertir(BigDecimal montant, Devise source, Devise cible) {
+ return convertir(montant, source, cible, LocalDate.now());
+ }
+
+ /** Récupère le taux applicable, avec stratégie de fallback. */
+ public BigDecimal trouverTaux(Devise source, Devise cible, LocalDate date) {
+ if (source == cible) return BigDecimal.ONE;
+
+ String cacheKey = source.name() + "->" + cible.name() + "@" + date;
+ CachedTaux cached = cache.get(cacheKey);
+ if (cached != null && !cached.expired()) {
+ return cached.taux;
+ }
+
+ BigDecimal taux = resolveTaux(source, cible, date);
+ cache.put(cacheKey, new CachedTaux(taux, Instant.now()));
+ return taux;
+ }
+
+ private BigDecimal resolveTaux(Devise source, Devise cible, LocalDate date) {
+ // 1. Direct exact
+ Optional direct = repository.trouverExact(source, cible, date);
+ if (direct.isPresent()) return direct.get().getTaux();
+
+ // 2. Inverse exact
+ Optional inverse = repository.trouverExact(cible, source, date);
+ if (inverse.isPresent()) {
+ return BigDecimal.ONE.divide(inverse.get().getTaux(), 8, RoundingMode.HALF_UP);
+ }
+
+ // 3. Pivot via XOF
+ if (source != Devise.XOF && cible != Devise.XOF) {
+ try {
+ BigDecimal sourceVersXof = trouverTauxSimple(source, Devise.XOF, date);
+ BigDecimal xofVersCible = trouverTauxSimple(Devise.XOF, cible, date);
+ return sourceVersXof.multiply(xofVersCible).setScale(8, RoundingMode.HALF_UP);
+ } catch (TauxIntrouvableException ignored) {
+ // Continue avec fallback
+ }
+ }
+
+ // 4. Plus récent ≤ date
+ Optional recent = repository.trouverPlusRecent(source, cible, date);
+ if (recent.isPresent()) {
+ LOG.debugf("Taux %s→%s : utilisation du taux %s du %s (date demandée: %s)",
+ source, cible, recent.get().getTaux(), recent.get().getDateValidite(), date);
+ return recent.get().getTaux();
+ }
+ Optional recentInverse = repository.trouverPlusRecent(cible, source, date);
+ if (recentInverse.isPresent()) {
+ return BigDecimal.ONE.divide(recentInverse.get().getTaux(), 8, RoundingMode.HALF_UP);
+ }
+
+ throw new TauxIntrouvableException(
+ "Aucun taux de change " + source + "→" + cible + " disponible (date: " + date + ")");
+ }
+
+ /** Recherche directe sans fallback (utilisée pour le pivot). */
+ private BigDecimal trouverTauxSimple(Devise source, Devise cible, LocalDate date) {
+ Optional direct = repository.trouverExact(source, cible, date);
+ if (direct.isPresent()) return direct.get().getTaux();
+
+ Optional inverse = repository.trouverExact(cible, source, date);
+ if (inverse.isPresent()) {
+ return BigDecimal.ONE.divide(inverse.get().getTaux(), 8, RoundingMode.HALF_UP);
+ }
+
+ Optional recent = repository.trouverPlusRecent(source, cible, date);
+ if (recent.isPresent()) return recent.get().getTaux();
+
+ Optional recentInverse = repository.trouverPlusRecent(cible, source, date);
+ if (recentInverse.isPresent()) {
+ return BigDecimal.ONE.divide(recentInverse.get().getTaux(), 8, RoundingMode.HALF_UP);
+ }
+
+ throw new TauxIntrouvableException(
+ "Pivot impossible : " + source + "→" + cible + " (date " + date + ")");
+ }
+
+ /** Vide le cache (utilisé après import batch de nouveaux taux). */
+ public void invaliderCache() {
+ cache.clear();
+ }
+
+ // ── DTOs internes ───────────────────────────────────────────────────────────
+
+ private record CachedTaux(BigDecimal taux, Instant cachedAt) {
+ boolean expired() {
+ return Duration.between(cachedAt, Instant.now()).compareTo(CACHE_TTL) > 0;
+ }
+ }
+
+ /** Levée quand aucun taux n'est résolvable. */
+ public static class TauxIntrouvableException extends RuntimeException {
+ public TauxIntrouvableException(String message) {
+ super(message);
+ }
+ }
+}
diff --git a/src/main/java/dev/lions/unionflow/server/service/diaspora/KycDiasporaService.java b/src/main/java/dev/lions/unionflow/server/service/diaspora/KycDiasporaService.java
new file mode 100644
index 0000000..f915471
--- /dev/null
+++ b/src/main/java/dev/lions/unionflow/server/service/diaspora/KycDiasporaService.java
@@ -0,0 +1,153 @@
+package dev.lions.unionflow.server.service.diaspora;
+
+import dev.lions.unionflow.server.entity.Devise;
+import dev.lions.unionflow.server.entity.KycDossier;
+import dev.lions.unionflow.server.entity.Membre;
+import jakarta.enterprise.context.ApplicationScoped;
+import java.math.BigDecimal;
+import java.util.List;
+import java.util.Set;
+import java.util.regex.Pattern;
+
+/**
+ * Validations spécifiques aux membres diaspora (non-résidents UEMOA).
+ *
+ * Couvre :
+ *
+ * - Cohérence des champs diaspora (passeport, pays résidence, fiscalité)
+ * - Détermination du niveau de due diligence (Instruction BCEAO 001-03-2025)
+ * - Validation du seuil AML pour transferts internationaux
+ *
+ *
+ * La liste FATF grey-list est synchronisée manuellement (à automatiser via veille).
+ *
+ * @since 2026-04-25 (P2-NEW-7)
+ */
+@ApplicationScoped
+public class KycDiasporaService {
+
+ /** Pays UEMOA (résidents non-diaspora). */
+ static final Set PAYS_UEMOA = Set.of(
+ "CIV", // Côte d'Ivoire
+ "BEN", // Bénin
+ "BFA", // Burkina Faso
+ "GNB", // Guinée-Bissau
+ "MLI", // Mali
+ "NER", // Niger
+ "SEN", // Sénégal
+ "TGO" // Togo
+ );
+
+ /** Liste FATF grey-list au 2026-04-25 (snapshot manuel). */
+ static final Set PAYS_GREY_LIST_FATF = Set.of(
+ "BLZ", "BGR", "BFA", "CMR", "COD", "GIB", "HTI", "JAM", "KEN",
+ "MLI", "MOZ", "NAM", "NGA", "PHL", "SEN", "SYR", "TZA", "TUR",
+ "VEN", "VNM", "YEM", "ZAF"
+ );
+
+ /** Seuil AML (en EUR) au-delà duquel un transfert international déclenche reporting. */
+ static final BigDecimal SEUIL_AML_INTERNATIONAL_EUR = new BigDecimal("1000");
+
+ /** Format passeport générique (alphanumérique 5-15 caractères selon pays). */
+ static final Pattern PASSEPORT_PATTERN = Pattern.compile("^[A-Z0-9]{5,15}$");
+
+ /**
+ * Valide la cohérence des champs diaspora d'un membre.
+ *
+ * @return liste des erreurs de validation (vide si tout est OK)
+ */
+ public List validerCoherence(Membre membre) {
+ if (membre == null) {
+ return List.of("Membre null");
+ }
+
+ List erreurs = new java.util.ArrayList<>();
+
+ if (Boolean.TRUE.equals(membre.getEstDiaspora())) {
+ // Si est_diaspora=true, paysResidence doit être renseigné et hors UEMOA
+ if (membre.getPaysResidence() == null || membre.getPaysResidence().isBlank()) {
+ erreurs.add("Membre diaspora : pays_residence obligatoire (ISO-3)");
+ } else if (PAYS_UEMOA.contains(membre.getPaysResidence())) {
+ erreurs.add("Membre diaspora : pays_residence ne peut être un pays UEMOA");
+ }
+ if (membre.getNumeroPasseport() == null || membre.getNumeroPasseport().isBlank()) {
+ erreurs.add("Membre diaspora : numero_passeport obligatoire");
+ } else if (!PASSEPORT_PATTERN.matcher(membre.getNumeroPasseport()).matches()) {
+ erreurs.add("numero_passeport : format invalide (5-15 caractères alphanumériques)");
+ }
+ } else {
+ // Membre non-diaspora : si paysResidence est posé, il doit être UEMOA
+ if (membre.getPaysResidence() != null && !membre.getPaysResidence().isBlank()
+ && !PAYS_UEMOA.contains(membre.getPaysResidence())) {
+ erreurs.add(
+ "Incohérence : pays_residence hors UEMOA mais est_diaspora=false. "
+ + "Mettre est_diaspora=true ou corriger pays_residence.");
+ }
+ }
+
+ return erreurs;
+ }
+
+ /**
+ * Détermine le niveau de due diligence applicable selon profil membre + KYC.
+ *
+ * Règles :
+ *
+ * - RENFORCE — diaspora hors UE/G7, PEP, FATF grey-list
+ * - STANDARD — diaspora UE/G7, KYC validé
+ * - SIMPLIFIE — résident UEMOA, opérations < seuil et risque faible
+ *
+ */
+ public String determinerNiveauDueDiligence(Membre membre, KycDossier kyc) {
+ if (membre == null || kyc == null) {
+ return "STANDARD";
+ }
+
+ if (kyc.isEstPep()) return "RENFORCE";
+
+ if (Boolean.TRUE.equals(membre.getEstDiaspora())) {
+ String pays = membre.getPaysResidence();
+ if (pays == null) return "RENFORCE";
+ if (PAYS_GREY_LIST_FATF.contains(pays)) return "RENFORCE";
+
+ // Diaspora "occidentale" sécurisée → STANDARD
+ Set paysSecurises = Set.of("FRA", "BEL", "DEU", "CHE", "ITA", "ESP", "PRT",
+ "GBR", "IRL", "USA", "CAN", "AUS", "NZL", "JPN", "KOR");
+ if (paysSecurises.contains(pays)) return "STANDARD";
+
+ // Autres pays diaspora → RENFORCE par défaut
+ return "RENFORCE";
+ }
+
+ return "STANDARD";
+ }
+
+ /**
+ * Vérifie si un transfert international dépasse le seuil de reporting AML.
+ *
+ * @param montant montant du transfert
+ * @param devise devise du montant
+ * @param tauxVersEur taux de change devise → EUR (BigDecimal). Si null et devise=EUR, OK.
+ * @return true si le transfert nécessite un reporting AML renforcé
+ */
+ public boolean depasseSeuilAmlInternational(BigDecimal montant, Devise devise,
+ BigDecimal tauxVersEur) {
+ if (montant == null) return false;
+ if (devise == null) return false;
+ // Seules les devises internationales déclenchent le reporting AML international.
+ // XOF/XAF et autres devises locales restent dans le seuil AML domestique BCEAO 001-03-2025.
+ if (!devise.estInternationale()) return false;
+
+ BigDecimal montantEur;
+ if (devise == Devise.EUR) {
+ montantEur = montant;
+ } else if (tauxVersEur == null) {
+ // Pas de taux disponible → considérer comme dépassé par prudence (false positive AML > false negative)
+ return true;
+ } else {
+ montantEur = montant.multiply(tauxVersEur);
+ }
+
+ return montantEur.compareTo(SEUIL_AML_INTERNATIONAL_EUR) >= 0;
+ }
+}
diff --git a/src/main/resources/db/migration/V49__P2_2026_04_25_Multi_Devise_Diaspora.sql b/src/main/resources/db/migration/V49__P2_2026_04_25_Multi_Devise_Diaspora.sql
new file mode 100644
index 0000000..46275c7
--- /dev/null
+++ b/src/main/resources/db/migration/V49__P2_2026_04_25_Multi_Devise_Diaspora.sql
@@ -0,0 +1,84 @@
+-- ============================================================================
+-- V49 — Multi-devise + KYC non-résident (P2-NEW-7)
+-- 2026-04-25
+--
+-- Ouvre UnionFlow à la diaspora UEMOA (France, USA, Canada, UK) avec :
+-- • Table de taux de change quotidiens
+-- • Champs membre/KYC pour non-résidents (passeport, fiscalité étrangère)
+-- ============================================================================
+
+-- ── Taux de change ──────────────────────────────────────────────────────────
+CREATE TABLE IF NOT EXISTS taux_change (
+ id UUID PRIMARY KEY,
+ devise_source VARCHAR(3) NOT NULL,
+ devise_cible VARCHAR(3) NOT NULL,
+ taux NUMERIC(18, 8) NOT NULL CHECK (taux > 0),
+ date_validite DATE NOT NULL,
+ source VARCHAR(50) NOT NULL DEFAULT 'BCEAO',
+ -- BaseEntity
+ date_creation TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ date_modification TIMESTAMP,
+ cree_par VARCHAR(255),
+ modifie_par VARCHAR(255),
+ version BIGINT,
+ actif BOOLEAN NOT NULL DEFAULT TRUE,
+ CONSTRAINT uq_taux_change_paire_date
+ UNIQUE (devise_source, devise_cible, date_validite),
+ CONSTRAINT chk_taux_change_devises_distinctes
+ CHECK (devise_source <> devise_cible)
+);
+
+CREATE INDEX IF NOT EXISTS idx_taux_change_paire
+ ON taux_change (devise_source, devise_cible);
+CREATE INDEX IF NOT EXISTS idx_taux_change_date_validite
+ ON taux_change (date_validite DESC);
+
+COMMENT ON TABLE taux_change IS
+ 'Taux de change quotidiens — source BCEAO/ECB/Fixer.io selon devise. Multi-devise diaspora.';
+COMMENT ON COLUMN taux_change.taux IS
+ 'Taux multiplicatif : 1 unité de devise_source = taux unités de devise_cible';
+
+-- Seed minimal (parité fixe XOF<->EUR + taux indicatifs au 2026-04-25)
+INSERT INTO taux_change (id, devise_source, devise_cible, taux, date_validite, source, actif)
+VALUES
+ (gen_random_uuid(), 'EUR', 'XOF', 655.957, '2026-04-25', 'BCEAO_FIXED', TRUE),
+ (gen_random_uuid(), 'XOF', 'EUR', 0.00152449, '2026-04-25', 'BCEAO_FIXED', TRUE),
+ (gen_random_uuid(), 'USD', 'XOF', 605.00, '2026-04-25', 'BCEAO', TRUE),
+ (gen_random_uuid(), 'XOF', 'USD', 0.00165289, '2026-04-25', 'BCEAO', TRUE),
+ (gen_random_uuid(), 'GBP', 'XOF', 765.00, '2026-04-25', 'BCEAO', TRUE),
+ (gen_random_uuid(), 'CAD', 'XOF', 440.00, '2026-04-25', 'BCEAO', TRUE)
+ON CONFLICT (devise_source, devise_cible, date_validite) DO NOTHING;
+
+-- ── Extension Membre (diaspora) ─────────────────────────────────────────────
+ALTER TABLE membres
+ ADD COLUMN IF NOT EXISTS pays_residence VARCHAR(3),
+ ADD COLUMN IF NOT EXISTS numero_passeport VARCHAR(50),
+ ADD COLUMN IF NOT EXISTS numero_fiscal_etranger VARCHAR(50),
+ ADD COLUMN IF NOT EXISTS est_diaspora BOOLEAN NOT NULL DEFAULT FALSE,
+ ADD COLUMN IF NOT EXISTS devise_preferee VARCHAR(3) NOT NULL DEFAULT 'XOF';
+
+CREATE INDEX IF NOT EXISTS idx_membre_diaspora
+ ON membres (est_diaspora) WHERE est_diaspora = TRUE;
+
+COMMENT ON COLUMN membres.pays_residence IS
+ 'ISO-3 (FRA, USA, CAN, GBR...). NULL = résident UEMOA. Différent de nationalite.';
+COMMENT ON COLUMN membres.numero_passeport IS
+ 'Passeport pour non-résidents (CNI insuffisante). Vérification ARTCI.';
+COMMENT ON COLUMN membres.numero_fiscal_etranger IS
+ 'NIF/SSN/SIN — pour reporting fiscal accord bilatéral CI-pays résidence.';
+
+-- ── Extension KycDossier (non-résident) ─────────────────────────────────────
+ALTER TABLE kyc_dossiers
+ ADD COLUMN IF NOT EXISTS pays_origine_fonds VARCHAR(3),
+ ADD COLUMN IF NOT EXISTS justificatif_residence_etrangere VARCHAR(500),
+ ADD COLUMN IF NOT EXISTS niveau_due_diligence VARCHAR(20) NOT NULL DEFAULT 'STANDARD'
+ CHECK (niveau_due_diligence IN ('SIMPLIFIE', 'STANDARD', 'RENFORCE'));
+
+CREATE INDEX IF NOT EXISTS idx_kyc_due_diligence
+ ON kyc_dossiers (niveau_due_diligence)
+ WHERE niveau_due_diligence = 'RENFORCE';
+
+COMMENT ON COLUMN kyc_dossiers.niveau_due_diligence IS
+ 'Instr. BCEAO 001-03-2025 : RENFORCE pour non-résidents, PEP, et risque pays grey-list FATF';
+COMMENT ON COLUMN kyc_dossiers.pays_origine_fonds IS
+ 'ISO-3 — origine des fonds pour transferts internationaux (anti-blanchiment).';
diff --git a/src/test/java/dev/lions/unionflow/server/entity/DeviseTest.java b/src/test/java/dev/lions/unionflow/server/entity/DeviseTest.java
new file mode 100644
index 0000000..956ddf2
--- /dev/null
+++ b/src/test/java/dev/lions/unionflow/server/entity/DeviseTest.java
@@ -0,0 +1,51 @@
+package dev.lions.unionflow.server.entity;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+class DeviseTest {
+
+ @Test
+ @DisplayName("Devise de référence = XOF")
+ void reference() {
+ assertThat(Devise.reference()).isEqualTo(Devise.XOF);
+ }
+
+ @Test
+ @DisplayName("EUR/USD/GBP/CAD/CHF marquées internationales")
+ void internationales() {
+ assertThat(Devise.EUR.estInternationale()).isTrue();
+ assertThat(Devise.USD.estInternationale()).isTrue();
+ assertThat(Devise.GBP.estInternationale()).isTrue();
+ assertThat(Devise.CAD.estInternationale()).isTrue();
+ assertThat(Devise.CHF.estInternationale()).isTrue();
+ }
+
+ @Test
+ @DisplayName("XOF/XAF non marquées internationales (zones franc CFA)")
+ void cfaNonInternationale() {
+ assertThat(Devise.XOF.estInternationale()).isFalse();
+ assertThat(Devise.XAF.estInternationale()).isFalse();
+ }
+
+ @Test
+ @DisplayName("Zones cohérentes")
+ void zones() {
+ assertThat(Devise.XOF.zone()).isEqualTo(Devise.ZoneDevise.UEMOA);
+ assertThat(Devise.XAF.zone()).isEqualTo(Devise.ZoneDevise.CEMAC);
+ assertThat(Devise.EUR.zone()).isEqualTo(Devise.ZoneDevise.EUROPE);
+ assertThat(Devise.USD.zone()).isEqualTo(Devise.ZoneDevise.AMERIQUE);
+ assertThat(Devise.GHS.zone()).isEqualTo(Devise.ZoneDevise.CEDEAO);
+ assertThat(Devise.MAD.zone()).isEqualTo(Devise.ZoneDevise.MAGHREB);
+ }
+
+ @Test
+ @DisplayName("Libellés non vides")
+ void libelles() {
+ for (Devise d : Devise.values()) {
+ assertThat(d.libelle()).isNotBlank();
+ }
+ }
+}
diff --git a/src/test/java/dev/lions/unionflow/server/service/devise/DeviseConversionServiceTest.java b/src/test/java/dev/lions/unionflow/server/service/devise/DeviseConversionServiceTest.java
new file mode 100644
index 0000000..37d3bfc
--- /dev/null
+++ b/src/test/java/dev/lions/unionflow/server/service/devise/DeviseConversionServiceTest.java
@@ -0,0 +1,181 @@
+package dev.lions.unionflow.server.service.devise;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.lenient;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import dev.lions.unionflow.server.entity.Devise;
+import dev.lions.unionflow.server.entity.TauxChange;
+import dev.lions.unionflow.server.repository.TauxChangeRepository;
+import java.lang.reflect.Field;
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.util.Optional;
+import org.assertj.core.data.Offset;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+@ExtendWith(MockitoExtension.class)
+class DeviseConversionServiceTest {
+
+ @Mock TauxChangeRepository repository;
+
+ private DeviseConversionService service;
+ private final LocalDate jour = LocalDate.of(2026, 4, 25);
+
+ @BeforeEach
+ void setUp() throws Exception {
+ service = new DeviseConversionService();
+ Field f = DeviseConversionService.class.getDeclaredField("repository");
+ f.setAccessible(true);
+ f.set(service, repository);
+ }
+
+ private TauxChange t(Devise s, Devise c, String taux, LocalDate date) {
+ TauxChange tc = new TauxChange();
+ tc.setDeviseSource(s);
+ tc.setDeviseCible(c);
+ tc.setTaux(new BigDecimal(taux));
+ tc.setDateValidite(date);
+ return tc;
+ }
+
+ // ── Cas trivial : devises identiques ─────────────────────────────────────
+
+ @Test
+ @DisplayName("convertir XOF→XOF retourne montant inchangé")
+ void identite() {
+ BigDecimal r = service.convertir(new BigDecimal("1000"), Devise.XOF, Devise.XOF, jour);
+ assertThat(r).isEqualByComparingTo("1000");
+ }
+
+ // ── Lookup direct ────────────────────────────────────────────────────────
+
+ @Test
+ @DisplayName("convertir EUR→XOF utilise le taux exact disponible")
+ void direct() {
+ when(repository.trouverExact(Devise.EUR, Devise.XOF, jour))
+ .thenReturn(Optional.of(t(Devise.EUR, Devise.XOF, "655.957", jour)));
+
+ BigDecimal r = service.convertir(new BigDecimal("100"), Devise.EUR, Devise.XOF, jour);
+ assertThat(r).isEqualByComparingTo("65595.7000");
+ }
+
+ // ── Inverse ───────────────────────────────────────────────────────────────
+
+ @Test
+ @DisplayName("convertir XOF→EUR utilise inverse si direct absent")
+ void inverse() {
+ when(repository.trouverExact(Devise.XOF, Devise.EUR, jour)).thenReturn(Optional.empty());
+ when(repository.trouverExact(Devise.EUR, Devise.XOF, jour))
+ .thenReturn(Optional.of(t(Devise.EUR, Devise.XOF, "655.957", jour)));
+
+ BigDecimal r = service.convertir(new BigDecimal("65595.70"), Devise.XOF, Devise.EUR, jour);
+ assertThat(r.doubleValue()).isCloseTo(100.0, Offset.offset(0.01));
+ }
+
+ // ── Pivot via XOF ─────────────────────────────────────────────────────────
+
+ @Test
+ @DisplayName("convertir EUR→USD utilise pivot via XOF")
+ void pivot() {
+ // Pas de direct EUR→USD
+ when(repository.trouverExact(Devise.EUR, Devise.USD, jour)).thenReturn(Optional.empty());
+ when(repository.trouverExact(Devise.USD, Devise.EUR, jour)).thenReturn(Optional.empty());
+ // Pivot : EUR→XOF puis XOF→USD
+ when(repository.trouverExact(Devise.EUR, Devise.XOF, jour))
+ .thenReturn(Optional.of(t(Devise.EUR, Devise.XOF, "655.957", jour)));
+ when(repository.trouverExact(Devise.XOF, Devise.USD, jour))
+ .thenReturn(Optional.of(t(Devise.XOF, Devise.USD, "0.00165289", jour)));
+ lenient().when(repository.trouverExact(Devise.XOF, Devise.EUR, jour))
+ .thenReturn(Optional.empty());
+ lenient().when(repository.trouverExact(Devise.USD, Devise.XOF, jour))
+ .thenReturn(Optional.empty());
+
+ BigDecimal r = service.convertir(new BigDecimal("100"), Devise.EUR, Devise.USD, jour);
+ // 100 EUR × 655.957 × 0.00165289 ≈ 108.42 USD
+ assertThat(r.doubleValue()).isCloseTo(108.42, Offset.offset(0.5));
+ }
+
+ // ── Fallback récent ──────────────────────────────────────────────────────
+
+ @Test
+ @DisplayName("convertir utilise le taux le plus récent ≤ date si pas de taux exact")
+ void fallbackRecent() {
+ when(repository.trouverExact(any(), any(), eq(jour))).thenReturn(Optional.empty());
+ when(repository.trouverPlusRecent(Devise.EUR, Devise.XOF, jour))
+ .thenReturn(Optional.of(t(Devise.EUR, Devise.XOF, "655.957", jour.minusDays(3))));
+ lenient().when(repository.trouverPlusRecent(Devise.XOF, Devise.EUR, jour))
+ .thenReturn(Optional.empty());
+
+ BigDecimal r = service.convertir(new BigDecimal("10"), Devise.EUR, Devise.XOF, jour);
+ assertThat(r.doubleValue()).isCloseTo(6559.57, Offset.offset(0.01));
+ }
+
+ // ── Aucun taux ──────────────────────────────────────────────────────────
+
+ @Test
+ @DisplayName("convertir sans aucun taux disponible → exception")
+ void aucunTaux() {
+ when(repository.trouverExact(any(), any(), any())).thenReturn(Optional.empty());
+ when(repository.trouverPlusRecent(any(), any(), any())).thenReturn(Optional.empty());
+
+ assertThatThrownBy(() ->
+ service.convertir(new BigDecimal("100"), Devise.GHS, Devise.NGN, jour))
+ .isInstanceOf(DeviseConversionService.TauxIntrouvableException.class);
+ }
+
+ // ── Cache ──────────────────────────────────────────────────────────────
+
+ @Test
+ @DisplayName("appels successifs même paire/date utilisent le cache")
+ void cache() {
+ when(repository.trouverExact(Devise.EUR, Devise.XOF, jour))
+ .thenReturn(Optional.of(t(Devise.EUR, Devise.XOF, "655.957", jour)));
+
+ service.convertir(new BigDecimal("1"), Devise.EUR, Devise.XOF, jour);
+ service.convertir(new BigDecimal("2"), Devise.EUR, Devise.XOF, jour);
+ service.convertir(new BigDecimal("3"), Devise.EUR, Devise.XOF, jour);
+
+ // 1 seul accès repository (cache après le premier appel)
+ verify(repository, times(1)).trouverExact(Devise.EUR, Devise.XOF, jour);
+ }
+
+ @Test
+ @DisplayName("invaliderCache force un rechargement du repository")
+ void invaliderCache() {
+ when(repository.trouverExact(Devise.EUR, Devise.XOF, jour))
+ .thenReturn(Optional.of(t(Devise.EUR, Devise.XOF, "655.957", jour)));
+
+ service.convertir(new BigDecimal("1"), Devise.EUR, Devise.XOF, jour);
+ service.invaliderCache();
+ service.convertir(new BigDecimal("1"), Devise.EUR, Devise.XOF, jour);
+
+ verify(repository, times(2)).trouverExact(Devise.EUR, Devise.XOF, jour);
+ }
+
+ // ── Inputs invalides ──────────────────────────────────────────────────
+
+ @Test
+ @DisplayName("montant null → IllegalArgumentException")
+ void montantNull() {
+ assertThatThrownBy(() -> service.convertir(null, Devise.EUR, Devise.XOF, jour))
+ .isInstanceOf(IllegalArgumentException.class);
+ }
+
+ @Test
+ @DisplayName("devise null → IllegalArgumentException")
+ void deviseNull() {
+ assertThatThrownBy(() -> service.convertir(BigDecimal.ONE, null, Devise.XOF, jour))
+ .isInstanceOf(IllegalArgumentException.class);
+ }
+}
diff --git a/src/test/java/dev/lions/unionflow/server/service/diaspora/KycDiasporaServiceTest.java b/src/test/java/dev/lions/unionflow/server/service/diaspora/KycDiasporaServiceTest.java
new file mode 100644
index 0000000..2509877
--- /dev/null
+++ b/src/test/java/dev/lions/unionflow/server/service/diaspora/KycDiasporaServiceTest.java
@@ -0,0 +1,181 @@
+package dev.lions.unionflow.server.service.diaspora;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import dev.lions.unionflow.server.entity.Devise;
+import dev.lions.unionflow.server.entity.KycDossier;
+import dev.lions.unionflow.server.entity.Membre;
+import java.math.BigDecimal;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+class KycDiasporaServiceTest {
+
+ private final KycDiasporaService service = new KycDiasporaService();
+
+ // ── validerCoherence ──────────────────────────────────────────────────────
+
+ @Test
+ @DisplayName("Membre null → erreur")
+ void coherence_null() {
+ assertThat(service.validerCoherence(null)).contains("Membre null");
+ }
+
+ @Test
+ @DisplayName("Diaspora avec passeport et FRA → OK")
+ void coherence_diasporaValide() {
+ Membre m = Membre.builder()
+ .estDiaspora(true)
+ .paysResidence("FRA")
+ .numeroPasseport("12AB34567")
+ .build();
+ assertThat(service.validerCoherence(m)).isEmpty();
+ }
+
+ @Test
+ @DisplayName("Diaspora sans passeport → erreur")
+ void coherence_diasporaSansPasseport() {
+ Membre m = Membre.builder()
+ .estDiaspora(true)
+ .paysResidence("FRA")
+ .build();
+ assertThat(service.validerCoherence(m))
+ .anyMatch(e -> e.contains("numero_passeport obligatoire"));
+ }
+
+ @Test
+ @DisplayName("Diaspora avec pays UEMOA → erreur")
+ void coherence_diasporaPaysUemoa() {
+ Membre m = Membre.builder()
+ .estDiaspora(true)
+ .paysResidence("CIV")
+ .numeroPasseport("CI1234567")
+ .build();
+ assertThat(service.validerCoherence(m))
+ .anyMatch(e -> e.contains("ne peut être un pays UEMOA"));
+ }
+
+ @Test
+ @DisplayName("Non-diaspora avec pays hors UEMOA → erreur")
+ void coherence_nonDiasporaPaysEtranger() {
+ Membre m = Membre.builder()
+ .estDiaspora(false)
+ .paysResidence("USA")
+ .build();
+ assertThat(service.validerCoherence(m))
+ .anyMatch(e -> e.contains("Incohérence"));
+ }
+
+ @Test
+ @DisplayName("Passeport format invalide → erreur")
+ void coherence_passeportInvalide() {
+ Membre m = Membre.builder()
+ .estDiaspora(true)
+ .paysResidence("FRA")
+ .numeroPasseport("xx") // trop court + minuscules
+ .build();
+ assertThat(service.validerCoherence(m))
+ .anyMatch(e -> e.contains("numero_passeport : format invalide"));
+ }
+
+ // ── determinerNiveauDueDiligence ─────────────────────────────────────────
+
+ @Test
+ @DisplayName("PEP → RENFORCE")
+ void due_pep() {
+ Membre m = Membre.builder().estDiaspora(false).build();
+ KycDossier k = KycDossier.builder().estPep(true).build();
+ assertThat(service.determinerNiveauDueDiligence(m, k)).isEqualTo("RENFORCE");
+ }
+
+ @Test
+ @DisplayName("Diaspora France → STANDARD")
+ void due_diasporaFra() {
+ Membre m = Membre.builder().estDiaspora(true).paysResidence("FRA").build();
+ KycDossier k = KycDossier.builder().estPep(false).build();
+ assertThat(service.determinerNiveauDueDiligence(m, k)).isEqualTo("STANDARD");
+ }
+
+ @Test
+ @DisplayName("Diaspora pays grey-list FATF → RENFORCE")
+ void due_diasporaGreyList() {
+ Membre m = Membre.builder().estDiaspora(true).paysResidence("PHL").build();
+ KycDossier k = KycDossier.builder().estPep(false).build();
+ assertThat(service.determinerNiveauDueDiligence(m, k)).isEqualTo("RENFORCE");
+ }
+
+ @Test
+ @DisplayName("Diaspora pays inconnu (ni sécurisé ni grey-list) → RENFORCE")
+ void due_diasporaPaysInconnu() {
+ Membre m = Membre.builder().estDiaspora(true).paysResidence("KAZ").build();
+ KycDossier k = KycDossier.builder().estPep(false).build();
+ assertThat(service.determinerNiveauDueDiligence(m, k)).isEqualTo("RENFORCE");
+ }
+
+ @Test
+ @DisplayName("Résident UEMOA + non PEP → STANDARD")
+ void due_residentUemoa() {
+ Membre m = Membre.builder().estDiaspora(false).build();
+ KycDossier k = KycDossier.builder().estPep(false).build();
+ assertThat(service.determinerNiveauDueDiligence(m, k)).isEqualTo("STANDARD");
+ }
+
+ @Test
+ @DisplayName("Membre ou KYC null → STANDARD défaut")
+ void due_null() {
+ assertThat(service.determinerNiveauDueDiligence(null, null)).isEqualTo("STANDARD");
+ }
+
+ // ── depasseSeuilAmlInternational ─────────────────────────────────────────
+
+ @Test
+ @DisplayName("EUR ≥ 1000 → dépasse seuil")
+ void seuil_eurAuDessus() {
+ assertThat(service.depasseSeuilAmlInternational(new BigDecimal("1500"), Devise.EUR, null))
+ .isTrue();
+ }
+
+ @Test
+ @DisplayName("EUR < 1000 → ne dépasse pas")
+ void seuil_eurEnDessous() {
+ assertThat(service.depasseSeuilAmlInternational(new BigDecimal("500"), Devise.EUR, null))
+ .isFalse();
+ }
+
+ @Test
+ @DisplayName("USD avec taux fourni — calcul correct")
+ void seuil_usdAvecTaux() {
+ // 1500 USD × 0.92 = 1380 EUR ≥ 1000
+ assertThat(service.depasseSeuilAmlInternational(new BigDecimal("1500"), Devise.USD,
+ new BigDecimal("0.92"))).isTrue();
+ // 500 USD × 0.92 = 460 EUR < 1000
+ assertThat(service.depasseSeuilAmlInternational(new BigDecimal("500"), Devise.USD,
+ new BigDecimal("0.92"))).isFalse();
+ }
+
+ @Test
+ @DisplayName("Sans taux disponible → considéré dépassé (prudence)")
+ void seuil_sansTauxPrudence() {
+ assertThat(service.depasseSeuilAmlInternational(new BigDecimal("100"), Devise.GBP, null))
+ .isTrue();
+ }
+
+ @Test
+ @DisplayName("XOF (devise locale) → ne déclenche pas reporting international")
+ void seuil_xof() {
+ assertThat(service.depasseSeuilAmlInternational(new BigDecimal("100000000"),
+ Devise.XOF, null)).isFalse();
+ }
+
+ @Test
+ @DisplayName("Montant null → false")
+ void seuil_montantNull() {
+ assertThat(service.depasseSeuilAmlInternational(null, Devise.EUR, null)).isFalse();
+ }
+
+ @Test
+ @DisplayName("Devise null → false")
+ void seuil_deviseNull() {
+ assertThat(service.depasseSeuilAmlInternational(BigDecimal.TEN, null, null)).isFalse();
+ }
+}