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

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:
dahoud
2026-04-25 10:33:05 +00:00
parent a0b2690c17
commit 86842f27af
11 changed files with 1004 additions and 0 deletions

View 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
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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