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
|
@Builder.Default
|
||||||
private int anneeReference = java.time.LocalDate.now().getYear();
|
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() {
|
public boolean isPieceExpiree() {
|
||||||
return dateExpirationPiece != null && dateExpirationPiece.isBefore(LocalDate.now());
|
return dateExpirationPiece != null && dateExpirationPiece.isBefore(LocalDate.now());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,6 +77,34 @@ public class Membre extends BaseEntity {
|
|||||||
@Column(name = "numero_cmu", length = 11)
|
@Column(name = "numero_cmu", length = 11)
|
||||||
private String numeroCMU;
|
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)")
|
@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;
|
||||||
|
|||||||
@@ -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