feat(sprint-6 P2-NEW-7 2026-04-25): multi-devise + KYC non-résident diaspora + tests
Some checks failed
CI/CD Pipeline / pipeline (push) Failing after 4m11s
Some checks failed
CI/CD Pipeline / pipeline (push) Failing after 4m11s
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)
This commit is contained in:
58
src/main/java/dev/lions/unionflow/server/entity/Devise.java
Normal file
58
src/main/java/dev/lions/unionflow/server/entity/Devise.java
Normal file
@@ -0,0 +1,58 @@
|
||||
package dev.lions.unionflow.server.entity;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Devises supportées par UnionFlow.
|
||||
*
|
||||
* <p>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<Devise> 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
|
||||
}
|
||||
}
|
||||
@@ -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) :
|
||||
* <ul>
|
||||
* <li>SIMPLIFIE — risque faible, opérations limitées</li>
|
||||
* <li>STANDARD — défaut</li>
|
||||
* <li>RENFORCE — non-résidents, PEP, FATF grey-list</li>
|
||||
* </ul>
|
||||
*/
|
||||
@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());
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
* <p>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";
|
||||
}
|
||||
@@ -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<TauxChange, UUID> {
|
||||
|
||||
/** Taux exact pour une paire à une date donnée. */
|
||||
public Optional<TauxChange> 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<TauxChange> 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();
|
||||
}
|
||||
}
|
||||
@@ -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}.
|
||||
*
|
||||
* <p>Stratégie de résolution :
|
||||
* <ol>
|
||||
* <li>Cas trivial : devises identiques → identité</li>
|
||||
* <li>Lookup taux exact (devise_source, devise_cible, date) — cache 1h</li>
|
||||
* <li>Lookup taux exact inverse → 1/taux</li>
|
||||
* <li>Pivot via XOF (chaîne) : source → XOF → cible</li>
|
||||
* <li>Fallback : taux le plus récent ≤ date</li>
|
||||
* <li>Sinon : exception</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>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<String, CachedTaux> 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<TauxChange> direct = repository.trouverExact(source, cible, date);
|
||||
if (direct.isPresent()) return direct.get().getTaux();
|
||||
|
||||
// 2. Inverse exact
|
||||
Optional<TauxChange> 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<TauxChange> 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<TauxChange> 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<TauxChange> direct = repository.trouverExact(source, cible, date);
|
||||
if (direct.isPresent()) return direct.get().getTaux();
|
||||
|
||||
Optional<TauxChange> inverse = repository.trouverExact(cible, source, date);
|
||||
if (inverse.isPresent()) {
|
||||
return BigDecimal.ONE.divide(inverse.get().getTaux(), 8, RoundingMode.HALF_UP);
|
||||
}
|
||||
|
||||
Optional<TauxChange> recent = repository.trouverPlusRecent(source, cible, date);
|
||||
if (recent.isPresent()) return recent.get().getTaux();
|
||||
|
||||
Optional<TauxChange> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
*
|
||||
* <p>Couvre :
|
||||
* <ul>
|
||||
* <li>Cohérence des champs diaspora (passeport, pays résidence, fiscalité)</li>
|
||||
* <li>Détermination du niveau de due diligence (Instruction BCEAO 001-03-2025)</li>
|
||||
* <li>Validation du seuil AML pour transferts internationaux</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>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<String> 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<String> 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<String> validerCoherence(Membre membre) {
|
||||
if (membre == null) {
|
||||
return List.of("Membre null");
|
||||
}
|
||||
|
||||
List<String> 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.
|
||||
*
|
||||
* <p>Règles :
|
||||
* <ul>
|
||||
* <li>RENFORCE — diaspora hors UE/G7, PEP, FATF grey-list</li>
|
||||
* <li>STANDARD — diaspora UE/G7, KYC validé</li>
|
||||
* <li>SIMPLIFIE — résident UEMOA, opérations < seuil et risque faible</li>
|
||||
* </ul>
|
||||
*/
|
||||
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<String> 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;
|
||||
}
|
||||
}
|
||||
@@ -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).';
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user