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).';
|
||||
Reference in New Issue
Block a user