diff --git a/src/main/java/dev/lions/unionflow/server/entity/Devise.java b/src/main/java/dev/lions/unionflow/server/entity/Devise.java new file mode 100644 index 0000000..25cbedc --- /dev/null +++ b/src/main/java/dev/lions/unionflow/server/entity/Devise.java @@ -0,0 +1,58 @@ +package dev.lions.unionflow.server.entity; + +import java.util.Set; + +/** + * Devises supportées par UnionFlow. + * + *

UnionFlow vise prioritairement la zone UEMOA (XOF/XAF) mais s'ouvre à la diaspora + * (EUR/USD/GBP/CAD). Le {@link ZoneDevise} permet de discriminer pour les règles + * AML (transferts internationaux, due diligence renforcée). + * + * @since 2026-04-25 (P2-NEW-7) + */ +public enum Devise { + + // Zone UEMOA / CEMAC + XOF("Franc CFA Ouest", ZoneDevise.UEMOA), + XAF("Franc CFA Centrale", ZoneDevise.CEMAC), + + // Diaspora — Europe / Amérique + EUR("Euro", ZoneDevise.EUROPE), + USD("Dollar US", ZoneDevise.AMERIQUE), + GBP("Livre Sterling", ZoneDevise.EUROPE), + CAD("Dollar Canadien", ZoneDevise.AMERIQUE), + CHF("Franc Suisse", ZoneDevise.EUROPE), + + // CEDEAO non-UEMOA (pour intégrations futures) + GHS("Cédi Ghanéen", ZoneDevise.CEDEAO), + NGN("Naira Nigérian", ZoneDevise.CEDEAO), + + // Maghreb + MAD("Dirham Marocain", ZoneDevise.MAGHREB); + + private final String libelle; + private final ZoneDevise zone; + + Devise(String libelle, ZoneDevise zone) { + this.libelle = libelle; + this.zone = zone; + } + + public String libelle() { return libelle; } + public ZoneDevise zone() { return zone; } + + /** Devise de référence UnionFlow / BCEAO. */ + public static Devise reference() { return XOF; } + + /** Devises pour lesquelles un transfert depuis/vers UEMOA déclenche AML renforcé. */ + public static final Set DEVISES_INTERNATIONALES = Set.of(EUR, USD, GBP, CAD, CHF); + + public boolean estInternationale() { + return DEVISES_INTERNATIONALES.contains(this); + } + + public enum ZoneDevise { + UEMOA, CEMAC, CEDEAO, EUROPE, AMERIQUE, MAGHREB + } +} diff --git a/src/main/java/dev/lions/unionflow/server/entity/KycDossier.java b/src/main/java/dev/lions/unionflow/server/entity/KycDossier.java index 00799e1..f969c2e 100644 --- a/src/main/java/dev/lions/unionflow/server/entity/KycDossier.java +++ b/src/main/java/dev/lions/unionflow/server/entity/KycDossier.java @@ -106,6 +106,29 @@ public class KycDossier extends BaseEntity { @Builder.Default private int anneeReference = java.time.LocalDate.now().getYear(); + /** Pays d'origine des fonds (ISO-3) — anti-blanchiment transferts internationaux. */ + @Size(max = 3) + @Column(name = "pays_origine_fonds", length = 3) + private String paysOrigineFonds; + + /** URL/chemin justificatif domicile étranger (facture EDF/British Gas/etc.) pour non-résidents. */ + @Size(max = 500) + @Column(name = "justificatif_residence_etrangere", length = 500) + private String justificatifResidenceEtrangere; + + /** + * Niveau de due diligence (Instr. BCEAO 001-03-2025) : + *

+ */ + @Size(max = 20) + @Column(name = "niveau_due_diligence", nullable = false, length = 20) + @Builder.Default + private String niveauDueDiligence = "STANDARD"; + public boolean isPieceExpiree() { return dateExpirationPiece != null && dateExpirationPiece.isBefore(LocalDate.now()); } diff --git a/src/main/java/dev/lions/unionflow/server/entity/Membre.java b/src/main/java/dev/lions/unionflow/server/entity/Membre.java index bd83e69..d78ba0b 100644 --- a/src/main/java/dev/lions/unionflow/server/entity/Membre.java +++ b/src/main/java/dev/lions/unionflow/server/entity/Membre.java @@ -77,6 +77,34 @@ public class Membre extends BaseEntity { @Column(name = "numero_cmu", length = 11) private String numeroCMU; + /** + * Pays de résidence (ISO-3, ex: FRA, USA, CAN). Différent de {@code nationalite} : + * un Ivoirien (CIV) résidant en France a paysResidence=FRA. NULL ou CIV = résident UEMOA. + * + * @since 2026-04-25 (P2-NEW-7) + */ + @Pattern(regexp = "^[A-Z]{3}$|^$", message = "Pays résidence doit être un code ISO-3") + @Column(name = "pays_residence", length = 3) + private String paysResidence; + + /** Numéro de passeport pour non-résidents (CNI insuffisante hors UEMOA). */ + @Column(name = "numero_passeport", length = 50) + private String numeroPasseport; + + /** NIF/SSN/SIN — reporting fiscal accord bilatéral CI ↔ pays résidence. */ + @Column(name = "numero_fiscal_etranger", length = 50) + private String numeroFiscalEtranger; + + /** TRUE si le membre est diaspora (résidence ≠ UEMOA). */ + @Builder.Default + @Column(name = "est_diaspora", nullable = false) + private Boolean estDiaspora = false; + + /** Devise préférée pour affichages et notifications (XOF par défaut). */ + @Builder.Default + @Column(name = "devise_preferee", nullable = false, length = 3) + private String devisePreferee = "XOF"; + @Pattern(regexp = "^\\+[1-9][0-9]{6,14}$", message = "Le numéro Wave doit être au format international E.164 (ex: +22507XXXXXXXX)") @Column(name = "telephone_wave", length = 20) private String telephoneWave; diff --git a/src/main/java/dev/lions/unionflow/server/entity/TauxChange.java b/src/main/java/dev/lions/unionflow/server/entity/TauxChange.java new file mode 100644 index 0000000..507939e --- /dev/null +++ b/src/main/java/dev/lions/unionflow/server/entity/TauxChange.java @@ -0,0 +1,62 @@ +package dev.lions.unionflow.server.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import jakarta.validation.constraints.DecimalMin; +import java.math.BigDecimal; +import java.time.LocalDate; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +/** + * Taux de change quotidien entre deux {@link Devise}s. + * + *

Source : BCEAO (officiel UEMOA), ECB, Fixer.io ou import manuel. Conservation + * historique pour audit et conversions rétroactives. + * + * @since 2026-04-25 (P2-NEW-7) + */ +@Entity +@Table(name = "taux_change", + uniqueConstraints = @UniqueConstraint( + name = "uq_taux_change_paire_date", + columnNames = {"devise_source", "devise_cible", "date_validite"}), + indexes = { + @Index(name = "idx_taux_change_paire", columnList = "devise_source,devise_cible"), + @Index(name = "idx_taux_change_date_validite", columnList = "date_validite") + }) +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@EqualsAndHashCode(callSuper = true) +public class TauxChange extends BaseEntity { + + @Enumerated(EnumType.STRING) + @Column(name = "devise_source", nullable = false, length = 3) + private Devise deviseSource; + + @Enumerated(EnumType.STRING) + @Column(name = "devise_cible", nullable = false, length = 3) + private Devise deviseCible; + + /** 1 unité de {@code deviseSource} = {@code taux} unités de {@code deviseCible}. */ + @DecimalMin(value = "0.00000001", inclusive = false) + @Column(name = "taux", nullable = false, precision = 18, scale = 8) + private BigDecimal taux; + + @Column(name = "date_validite", nullable = false) + private LocalDate dateValidite; + + @Builder.Default + @Column(name = "source", nullable = false, length = 50) + private String source = "BCEAO"; +} diff --git a/src/main/java/dev/lions/unionflow/server/repository/TauxChangeRepository.java b/src/main/java/dev/lions/unionflow/server/repository/TauxChangeRepository.java new file mode 100644 index 0000000..40e9333 --- /dev/null +++ b/src/main/java/dev/lions/unionflow/server/repository/TauxChangeRepository.java @@ -0,0 +1,27 @@ +package dev.lions.unionflow.server.repository; + +import dev.lions.unionflow.server.entity.Devise; +import dev.lions.unionflow.server.entity.TauxChange; +import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase; +import jakarta.enterprise.context.ApplicationScoped; +import java.time.LocalDate; +import java.util.Optional; +import java.util.UUID; + +/** Repository des taux de change historisés. */ +@ApplicationScoped +public class TauxChangeRepository implements PanacheRepositoryBase { + + /** Taux exact pour une paire à une date donnée. */ + public Optional trouverExact(Devise source, Devise cible, LocalDate date) { + return find("deviseSource = ?1 AND deviseCible = ?2 AND dateValidite = ?3", + source, cible, date).firstResultOptional(); + } + + /** Taux le plus récent pour une paire (≤ date donnée). */ + public Optional trouverPlusRecent(Devise source, Devise cible, LocalDate dateMax) { + return find("deviseSource = ?1 AND deviseCible = ?2 AND dateValidite <= ?3 " + + "ORDER BY dateValidite DESC", source, cible, dateMax) + .firstResultOptional(); + } +} diff --git a/src/main/java/dev/lions/unionflow/server/service/devise/DeviseConversionService.java b/src/main/java/dev/lions/unionflow/server/service/devise/DeviseConversionService.java new file mode 100644 index 0000000..1e0827a --- /dev/null +++ b/src/main/java/dev/lions/unionflow/server/service/devise/DeviseConversionService.java @@ -0,0 +1,156 @@ +package dev.lions.unionflow.server.service.devise; + +import dev.lions.unionflow.server.entity.Devise; +import dev.lions.unionflow.server.entity.TauxChange; +import dev.lions.unionflow.server.repository.TauxChangeRepository; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDate; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import org.jboss.logging.Logger; + +/** + * Service de conversion entre devises basé sur la table {@code taux_change}. + * + *

Stratégie de résolution : + *

    + *
  1. Cas trivial : devises identiques → identité
  2. + *
  3. Lookup taux exact (devise_source, devise_cible, date) — cache 1h
  4. + *
  5. Lookup taux exact inverse → 1/taux
  6. + *
  7. Pivot via XOF (chaîne) : source → XOF → cible
  8. + *
  9. Fallback : taux le plus récent ≤ date
  10. + *
  11. Sinon : exception
  12. + *
+ * + *

Pas d'appel HTTP automatique ici — la mise à jour des taux est faite par un job batch + * dédié (à venir). Le seed V49 fournit les paires principales. + * + * @since 2026-04-25 (P2-NEW-7) + */ +@ApplicationScoped +public class DeviseConversionService { + + private static final Logger LOG = Logger.getLogger(DeviseConversionService.class); + private static final Duration CACHE_TTL = Duration.ofHours(1); + private static final int SCALE_RESULTAT = 4; + + @Inject TauxChangeRepository repository; + + /** Cache thread-safe : (source, cible, date) → (taux, timestamp). */ + private final ConcurrentHashMap cache = new ConcurrentHashMap<>(); + + /** Convertit {@code montant} de {@code source} vers {@code cible} à la date donnée. */ + public BigDecimal convertir(BigDecimal montant, Devise source, Devise cible, LocalDate date) { + if (montant == null) throw new IllegalArgumentException("montant null"); + if (source == null || cible == null) throw new IllegalArgumentException("devise null"); + if (source == cible) return montant; + + BigDecimal taux = trouverTaux(source, cible, date); + return montant.multiply(taux).setScale(SCALE_RESULTAT, RoundingMode.HALF_UP); + } + + /** Convertit à la date du jour. */ + public BigDecimal convertir(BigDecimal montant, Devise source, Devise cible) { + return convertir(montant, source, cible, LocalDate.now()); + } + + /** Récupère le taux applicable, avec stratégie de fallback. */ + public BigDecimal trouverTaux(Devise source, Devise cible, LocalDate date) { + if (source == cible) return BigDecimal.ONE; + + String cacheKey = source.name() + "->" + cible.name() + "@" + date; + CachedTaux cached = cache.get(cacheKey); + if (cached != null && !cached.expired()) { + return cached.taux; + } + + BigDecimal taux = resolveTaux(source, cible, date); + cache.put(cacheKey, new CachedTaux(taux, Instant.now())); + return taux; + } + + private BigDecimal resolveTaux(Devise source, Devise cible, LocalDate date) { + // 1. Direct exact + Optional direct = repository.trouverExact(source, cible, date); + if (direct.isPresent()) return direct.get().getTaux(); + + // 2. Inverse exact + Optional inverse = repository.trouverExact(cible, source, date); + if (inverse.isPresent()) { + return BigDecimal.ONE.divide(inverse.get().getTaux(), 8, RoundingMode.HALF_UP); + } + + // 3. Pivot via XOF + if (source != Devise.XOF && cible != Devise.XOF) { + try { + BigDecimal sourceVersXof = trouverTauxSimple(source, Devise.XOF, date); + BigDecimal xofVersCible = trouverTauxSimple(Devise.XOF, cible, date); + return sourceVersXof.multiply(xofVersCible).setScale(8, RoundingMode.HALF_UP); + } catch (TauxIntrouvableException ignored) { + // Continue avec fallback + } + } + + // 4. Plus récent ≤ date + Optional recent = repository.trouverPlusRecent(source, cible, date); + if (recent.isPresent()) { + LOG.debugf("Taux %s→%s : utilisation du taux %s du %s (date demandée: %s)", + source, cible, recent.get().getTaux(), recent.get().getDateValidite(), date); + return recent.get().getTaux(); + } + Optional recentInverse = repository.trouverPlusRecent(cible, source, date); + if (recentInverse.isPresent()) { + return BigDecimal.ONE.divide(recentInverse.get().getTaux(), 8, RoundingMode.HALF_UP); + } + + throw new TauxIntrouvableException( + "Aucun taux de change " + source + "→" + cible + " disponible (date: " + date + ")"); + } + + /** Recherche directe sans fallback (utilisée pour le pivot). */ + private BigDecimal trouverTauxSimple(Devise source, Devise cible, LocalDate date) { + Optional direct = repository.trouverExact(source, cible, date); + if (direct.isPresent()) return direct.get().getTaux(); + + Optional inverse = repository.trouverExact(cible, source, date); + if (inverse.isPresent()) { + return BigDecimal.ONE.divide(inverse.get().getTaux(), 8, RoundingMode.HALF_UP); + } + + Optional recent = repository.trouverPlusRecent(source, cible, date); + if (recent.isPresent()) return recent.get().getTaux(); + + Optional recentInverse = repository.trouverPlusRecent(cible, source, date); + if (recentInverse.isPresent()) { + return BigDecimal.ONE.divide(recentInverse.get().getTaux(), 8, RoundingMode.HALF_UP); + } + + throw new TauxIntrouvableException( + "Pivot impossible : " + source + "→" + cible + " (date " + date + ")"); + } + + /** Vide le cache (utilisé après import batch de nouveaux taux). */ + public void invaliderCache() { + cache.clear(); + } + + // ── DTOs internes ─────────────────────────────────────────────────────────── + + private record CachedTaux(BigDecimal taux, Instant cachedAt) { + boolean expired() { + return Duration.between(cachedAt, Instant.now()).compareTo(CACHE_TTL) > 0; + } + } + + /** Levée quand aucun taux n'est résolvable. */ + public static class TauxIntrouvableException extends RuntimeException { + public TauxIntrouvableException(String message) { + super(message); + } + } +} diff --git a/src/main/java/dev/lions/unionflow/server/service/diaspora/KycDiasporaService.java b/src/main/java/dev/lions/unionflow/server/service/diaspora/KycDiasporaService.java new file mode 100644 index 0000000..f915471 --- /dev/null +++ b/src/main/java/dev/lions/unionflow/server/service/diaspora/KycDiasporaService.java @@ -0,0 +1,153 @@ +package dev.lions.unionflow.server.service.diaspora; + +import dev.lions.unionflow.server.entity.Devise; +import dev.lions.unionflow.server.entity.KycDossier; +import dev.lions.unionflow.server.entity.Membre; +import jakarta.enterprise.context.ApplicationScoped; +import java.math.BigDecimal; +import java.util.List; +import java.util.Set; +import java.util.regex.Pattern; + +/** + * Validations spécifiques aux membres diaspora (non-résidents UEMOA). + * + *

Couvre : + *

+ * + *

La liste FATF grey-list est synchronisée manuellement (à automatiser via veille). + * + * @since 2026-04-25 (P2-NEW-7) + */ +@ApplicationScoped +public class KycDiasporaService { + + /** Pays UEMOA (résidents non-diaspora). */ + static final Set PAYS_UEMOA = Set.of( + "CIV", // Côte d'Ivoire + "BEN", // Bénin + "BFA", // Burkina Faso + "GNB", // Guinée-Bissau + "MLI", // Mali + "NER", // Niger + "SEN", // Sénégal + "TGO" // Togo + ); + + /** Liste FATF grey-list au 2026-04-25 (snapshot manuel). */ + static final Set PAYS_GREY_LIST_FATF = Set.of( + "BLZ", "BGR", "BFA", "CMR", "COD", "GIB", "HTI", "JAM", "KEN", + "MLI", "MOZ", "NAM", "NGA", "PHL", "SEN", "SYR", "TZA", "TUR", + "VEN", "VNM", "YEM", "ZAF" + ); + + /** Seuil AML (en EUR) au-delà duquel un transfert international déclenche reporting. */ + static final BigDecimal SEUIL_AML_INTERNATIONAL_EUR = new BigDecimal("1000"); + + /** Format passeport générique (alphanumérique 5-15 caractères selon pays). */ + static final Pattern PASSEPORT_PATTERN = Pattern.compile("^[A-Z0-9]{5,15}$"); + + /** + * Valide la cohérence des champs diaspora d'un membre. + * + * @return liste des erreurs de validation (vide si tout est OK) + */ + public List validerCoherence(Membre membre) { + if (membre == null) { + return List.of("Membre null"); + } + + List erreurs = new java.util.ArrayList<>(); + + if (Boolean.TRUE.equals(membre.getEstDiaspora())) { + // Si est_diaspora=true, paysResidence doit être renseigné et hors UEMOA + if (membre.getPaysResidence() == null || membre.getPaysResidence().isBlank()) { + erreurs.add("Membre diaspora : pays_residence obligatoire (ISO-3)"); + } else if (PAYS_UEMOA.contains(membre.getPaysResidence())) { + erreurs.add("Membre diaspora : pays_residence ne peut être un pays UEMOA"); + } + if (membre.getNumeroPasseport() == null || membre.getNumeroPasseport().isBlank()) { + erreurs.add("Membre diaspora : numero_passeport obligatoire"); + } else if (!PASSEPORT_PATTERN.matcher(membre.getNumeroPasseport()).matches()) { + erreurs.add("numero_passeport : format invalide (5-15 caractères alphanumériques)"); + } + } else { + // Membre non-diaspora : si paysResidence est posé, il doit être UEMOA + if (membre.getPaysResidence() != null && !membre.getPaysResidence().isBlank() + && !PAYS_UEMOA.contains(membre.getPaysResidence())) { + erreurs.add( + "Incohérence : pays_residence hors UEMOA mais est_diaspora=false. " + + "Mettre est_diaspora=true ou corriger pays_residence."); + } + } + + return erreurs; + } + + /** + * Détermine le niveau de due diligence applicable selon profil membre + KYC. + * + *

Règles : + *

+ */ + public String determinerNiveauDueDiligence(Membre membre, KycDossier kyc) { + if (membre == null || kyc == null) { + return "STANDARD"; + } + + if (kyc.isEstPep()) return "RENFORCE"; + + if (Boolean.TRUE.equals(membre.getEstDiaspora())) { + String pays = membre.getPaysResidence(); + if (pays == null) return "RENFORCE"; + if (PAYS_GREY_LIST_FATF.contains(pays)) return "RENFORCE"; + + // Diaspora "occidentale" sécurisée → STANDARD + Set paysSecurises = Set.of("FRA", "BEL", "DEU", "CHE", "ITA", "ESP", "PRT", + "GBR", "IRL", "USA", "CAN", "AUS", "NZL", "JPN", "KOR"); + if (paysSecurises.contains(pays)) return "STANDARD"; + + // Autres pays diaspora → RENFORCE par défaut + return "RENFORCE"; + } + + return "STANDARD"; + } + + /** + * Vérifie si un transfert international dépasse le seuil de reporting AML. + * + * @param montant montant du transfert + * @param devise devise du montant + * @param tauxVersEur taux de change devise → EUR (BigDecimal). Si null et devise=EUR, OK. + * @return true si le transfert nécessite un reporting AML renforcé + */ + public boolean depasseSeuilAmlInternational(BigDecimal montant, Devise devise, + BigDecimal tauxVersEur) { + if (montant == null) return false; + if (devise == null) return false; + // Seules les devises internationales déclenchent le reporting AML international. + // XOF/XAF et autres devises locales restent dans le seuil AML domestique BCEAO 001-03-2025. + if (!devise.estInternationale()) return false; + + BigDecimal montantEur; + if (devise == Devise.EUR) { + montantEur = montant; + } else if (tauxVersEur == null) { + // Pas de taux disponible → considérer comme dépassé par prudence (false positive AML > false negative) + return true; + } else { + montantEur = montant.multiply(tauxVersEur); + } + + return montantEur.compareTo(SEUIL_AML_INTERNATIONAL_EUR) >= 0; + } +} diff --git a/src/main/resources/db/migration/V49__P2_2026_04_25_Multi_Devise_Diaspora.sql b/src/main/resources/db/migration/V49__P2_2026_04_25_Multi_Devise_Diaspora.sql new file mode 100644 index 0000000..46275c7 --- /dev/null +++ b/src/main/resources/db/migration/V49__P2_2026_04_25_Multi_Devise_Diaspora.sql @@ -0,0 +1,84 @@ +-- ============================================================================ +-- V49 — Multi-devise + KYC non-résident (P2-NEW-7) +-- 2026-04-25 +-- +-- Ouvre UnionFlow à la diaspora UEMOA (France, USA, Canada, UK) avec : +-- • Table de taux de change quotidiens +-- • Champs membre/KYC pour non-résidents (passeport, fiscalité étrangère) +-- ============================================================================ + +-- ── Taux de change ────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS taux_change ( + id UUID PRIMARY KEY, + devise_source VARCHAR(3) NOT NULL, + devise_cible VARCHAR(3) NOT NULL, + taux NUMERIC(18, 8) NOT NULL CHECK (taux > 0), + date_validite DATE NOT NULL, + source VARCHAR(50) NOT NULL DEFAULT 'BCEAO', + -- BaseEntity + date_creation TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + date_modification TIMESTAMP, + cree_par VARCHAR(255), + modifie_par VARCHAR(255), + version BIGINT, + actif BOOLEAN NOT NULL DEFAULT TRUE, + CONSTRAINT uq_taux_change_paire_date + UNIQUE (devise_source, devise_cible, date_validite), + CONSTRAINT chk_taux_change_devises_distinctes + CHECK (devise_source <> devise_cible) +); + +CREATE INDEX IF NOT EXISTS idx_taux_change_paire + ON taux_change (devise_source, devise_cible); +CREATE INDEX IF NOT EXISTS idx_taux_change_date_validite + ON taux_change (date_validite DESC); + +COMMENT ON TABLE taux_change IS + 'Taux de change quotidiens — source BCEAO/ECB/Fixer.io selon devise. Multi-devise diaspora.'; +COMMENT ON COLUMN taux_change.taux IS + 'Taux multiplicatif : 1 unité de devise_source = taux unités de devise_cible'; + +-- Seed minimal (parité fixe XOF<->EUR + taux indicatifs au 2026-04-25) +INSERT INTO taux_change (id, devise_source, devise_cible, taux, date_validite, source, actif) +VALUES + (gen_random_uuid(), 'EUR', 'XOF', 655.957, '2026-04-25', 'BCEAO_FIXED', TRUE), + (gen_random_uuid(), 'XOF', 'EUR', 0.00152449, '2026-04-25', 'BCEAO_FIXED', TRUE), + (gen_random_uuid(), 'USD', 'XOF', 605.00, '2026-04-25', 'BCEAO', TRUE), + (gen_random_uuid(), 'XOF', 'USD', 0.00165289, '2026-04-25', 'BCEAO', TRUE), + (gen_random_uuid(), 'GBP', 'XOF', 765.00, '2026-04-25', 'BCEAO', TRUE), + (gen_random_uuid(), 'CAD', 'XOF', 440.00, '2026-04-25', 'BCEAO', TRUE) +ON CONFLICT (devise_source, devise_cible, date_validite) DO NOTHING; + +-- ── Extension Membre (diaspora) ───────────────────────────────────────────── +ALTER TABLE membres + ADD COLUMN IF NOT EXISTS pays_residence VARCHAR(3), + ADD COLUMN IF NOT EXISTS numero_passeport VARCHAR(50), + ADD COLUMN IF NOT EXISTS numero_fiscal_etranger VARCHAR(50), + ADD COLUMN IF NOT EXISTS est_diaspora BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN IF NOT EXISTS devise_preferee VARCHAR(3) NOT NULL DEFAULT 'XOF'; + +CREATE INDEX IF NOT EXISTS idx_membre_diaspora + ON membres (est_diaspora) WHERE est_diaspora = TRUE; + +COMMENT ON COLUMN membres.pays_residence IS + 'ISO-3 (FRA, USA, CAN, GBR...). NULL = résident UEMOA. Différent de nationalite.'; +COMMENT ON COLUMN membres.numero_passeport IS + 'Passeport pour non-résidents (CNI insuffisante). Vérification ARTCI.'; +COMMENT ON COLUMN membres.numero_fiscal_etranger IS + 'NIF/SSN/SIN — pour reporting fiscal accord bilatéral CI-pays résidence.'; + +-- ── Extension KycDossier (non-résident) ───────────────────────────────────── +ALTER TABLE kyc_dossiers + ADD COLUMN IF NOT EXISTS pays_origine_fonds VARCHAR(3), + ADD COLUMN IF NOT EXISTS justificatif_residence_etrangere VARCHAR(500), + ADD COLUMN IF NOT EXISTS niveau_due_diligence VARCHAR(20) NOT NULL DEFAULT 'STANDARD' + CHECK (niveau_due_diligence IN ('SIMPLIFIE', 'STANDARD', 'RENFORCE')); + +CREATE INDEX IF NOT EXISTS idx_kyc_due_diligence + ON kyc_dossiers (niveau_due_diligence) + WHERE niveau_due_diligence = 'RENFORCE'; + +COMMENT ON COLUMN kyc_dossiers.niveau_due_diligence IS + 'Instr. BCEAO 001-03-2025 : RENFORCE pour non-résidents, PEP, et risque pays grey-list FATF'; +COMMENT ON COLUMN kyc_dossiers.pays_origine_fonds IS + 'ISO-3 — origine des fonds pour transferts internationaux (anti-blanchiment).'; diff --git a/src/test/java/dev/lions/unionflow/server/entity/DeviseTest.java b/src/test/java/dev/lions/unionflow/server/entity/DeviseTest.java new file mode 100644 index 0000000..956ddf2 --- /dev/null +++ b/src/test/java/dev/lions/unionflow/server/entity/DeviseTest.java @@ -0,0 +1,51 @@ +package dev.lions.unionflow.server.entity; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class DeviseTest { + + @Test + @DisplayName("Devise de référence = XOF") + void reference() { + assertThat(Devise.reference()).isEqualTo(Devise.XOF); + } + + @Test + @DisplayName("EUR/USD/GBP/CAD/CHF marquées internationales") + void internationales() { + assertThat(Devise.EUR.estInternationale()).isTrue(); + assertThat(Devise.USD.estInternationale()).isTrue(); + assertThat(Devise.GBP.estInternationale()).isTrue(); + assertThat(Devise.CAD.estInternationale()).isTrue(); + assertThat(Devise.CHF.estInternationale()).isTrue(); + } + + @Test + @DisplayName("XOF/XAF non marquées internationales (zones franc CFA)") + void cfaNonInternationale() { + assertThat(Devise.XOF.estInternationale()).isFalse(); + assertThat(Devise.XAF.estInternationale()).isFalse(); + } + + @Test + @DisplayName("Zones cohérentes") + void zones() { + assertThat(Devise.XOF.zone()).isEqualTo(Devise.ZoneDevise.UEMOA); + assertThat(Devise.XAF.zone()).isEqualTo(Devise.ZoneDevise.CEMAC); + assertThat(Devise.EUR.zone()).isEqualTo(Devise.ZoneDevise.EUROPE); + assertThat(Devise.USD.zone()).isEqualTo(Devise.ZoneDevise.AMERIQUE); + assertThat(Devise.GHS.zone()).isEqualTo(Devise.ZoneDevise.CEDEAO); + assertThat(Devise.MAD.zone()).isEqualTo(Devise.ZoneDevise.MAGHREB); + } + + @Test + @DisplayName("Libellés non vides") + void libelles() { + for (Devise d : Devise.values()) { + assertThat(d.libelle()).isNotBlank(); + } + } +} diff --git a/src/test/java/dev/lions/unionflow/server/service/devise/DeviseConversionServiceTest.java b/src/test/java/dev/lions/unionflow/server/service/devise/DeviseConversionServiceTest.java new file mode 100644 index 0000000..37d3bfc --- /dev/null +++ b/src/test/java/dev/lions/unionflow/server/service/devise/DeviseConversionServiceTest.java @@ -0,0 +1,181 @@ +package dev.lions.unionflow.server.service.devise; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import dev.lions.unionflow.server.entity.Devise; +import dev.lions.unionflow.server.entity.TauxChange; +import dev.lions.unionflow.server.repository.TauxChangeRepository; +import java.lang.reflect.Field; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.Optional; +import org.assertj.core.data.Offset; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class DeviseConversionServiceTest { + + @Mock TauxChangeRepository repository; + + private DeviseConversionService service; + private final LocalDate jour = LocalDate.of(2026, 4, 25); + + @BeforeEach + void setUp() throws Exception { + service = new DeviseConversionService(); + Field f = DeviseConversionService.class.getDeclaredField("repository"); + f.setAccessible(true); + f.set(service, repository); + } + + private TauxChange t(Devise s, Devise c, String taux, LocalDate date) { + TauxChange tc = new TauxChange(); + tc.setDeviseSource(s); + tc.setDeviseCible(c); + tc.setTaux(new BigDecimal(taux)); + tc.setDateValidite(date); + return tc; + } + + // ── Cas trivial : devises identiques ───────────────────────────────────── + + @Test + @DisplayName("convertir XOF→XOF retourne montant inchangé") + void identite() { + BigDecimal r = service.convertir(new BigDecimal("1000"), Devise.XOF, Devise.XOF, jour); + assertThat(r).isEqualByComparingTo("1000"); + } + + // ── Lookup direct ──────────────────────────────────────────────────────── + + @Test + @DisplayName("convertir EUR→XOF utilise le taux exact disponible") + void direct() { + when(repository.trouverExact(Devise.EUR, Devise.XOF, jour)) + .thenReturn(Optional.of(t(Devise.EUR, Devise.XOF, "655.957", jour))); + + BigDecimal r = service.convertir(new BigDecimal("100"), Devise.EUR, Devise.XOF, jour); + assertThat(r).isEqualByComparingTo("65595.7000"); + } + + // ── Inverse ─────────────────────────────────────────────────────────────── + + @Test + @DisplayName("convertir XOF→EUR utilise inverse si direct absent") + void inverse() { + when(repository.trouverExact(Devise.XOF, Devise.EUR, jour)).thenReturn(Optional.empty()); + when(repository.trouverExact(Devise.EUR, Devise.XOF, jour)) + .thenReturn(Optional.of(t(Devise.EUR, Devise.XOF, "655.957", jour))); + + BigDecimal r = service.convertir(new BigDecimal("65595.70"), Devise.XOF, Devise.EUR, jour); + assertThat(r.doubleValue()).isCloseTo(100.0, Offset.offset(0.01)); + } + + // ── Pivot via XOF ───────────────────────────────────────────────────────── + + @Test + @DisplayName("convertir EUR→USD utilise pivot via XOF") + void pivot() { + // Pas de direct EUR→USD + when(repository.trouverExact(Devise.EUR, Devise.USD, jour)).thenReturn(Optional.empty()); + when(repository.trouverExact(Devise.USD, Devise.EUR, jour)).thenReturn(Optional.empty()); + // Pivot : EUR→XOF puis XOF→USD + when(repository.trouverExact(Devise.EUR, Devise.XOF, jour)) + .thenReturn(Optional.of(t(Devise.EUR, Devise.XOF, "655.957", jour))); + when(repository.trouverExact(Devise.XOF, Devise.USD, jour)) + .thenReturn(Optional.of(t(Devise.XOF, Devise.USD, "0.00165289", jour))); + lenient().when(repository.trouverExact(Devise.XOF, Devise.EUR, jour)) + .thenReturn(Optional.empty()); + lenient().when(repository.trouverExact(Devise.USD, Devise.XOF, jour)) + .thenReturn(Optional.empty()); + + BigDecimal r = service.convertir(new BigDecimal("100"), Devise.EUR, Devise.USD, jour); + // 100 EUR × 655.957 × 0.00165289 ≈ 108.42 USD + assertThat(r.doubleValue()).isCloseTo(108.42, Offset.offset(0.5)); + } + + // ── Fallback récent ────────────────────────────────────────────────────── + + @Test + @DisplayName("convertir utilise le taux le plus récent ≤ date si pas de taux exact") + void fallbackRecent() { + when(repository.trouverExact(any(), any(), eq(jour))).thenReturn(Optional.empty()); + when(repository.trouverPlusRecent(Devise.EUR, Devise.XOF, jour)) + .thenReturn(Optional.of(t(Devise.EUR, Devise.XOF, "655.957", jour.minusDays(3)))); + lenient().when(repository.trouverPlusRecent(Devise.XOF, Devise.EUR, jour)) + .thenReturn(Optional.empty()); + + BigDecimal r = service.convertir(new BigDecimal("10"), Devise.EUR, Devise.XOF, jour); + assertThat(r.doubleValue()).isCloseTo(6559.57, Offset.offset(0.01)); + } + + // ── Aucun taux ────────────────────────────────────────────────────────── + + @Test + @DisplayName("convertir sans aucun taux disponible → exception") + void aucunTaux() { + when(repository.trouverExact(any(), any(), any())).thenReturn(Optional.empty()); + when(repository.trouverPlusRecent(any(), any(), any())).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> + service.convertir(new BigDecimal("100"), Devise.GHS, Devise.NGN, jour)) + .isInstanceOf(DeviseConversionService.TauxIntrouvableException.class); + } + + // ── Cache ────────────────────────────────────────────────────────────── + + @Test + @DisplayName("appels successifs même paire/date utilisent le cache") + void cache() { + when(repository.trouverExact(Devise.EUR, Devise.XOF, jour)) + .thenReturn(Optional.of(t(Devise.EUR, Devise.XOF, "655.957", jour))); + + service.convertir(new BigDecimal("1"), Devise.EUR, Devise.XOF, jour); + service.convertir(new BigDecimal("2"), Devise.EUR, Devise.XOF, jour); + service.convertir(new BigDecimal("3"), Devise.EUR, Devise.XOF, jour); + + // 1 seul accès repository (cache après le premier appel) + verify(repository, times(1)).trouverExact(Devise.EUR, Devise.XOF, jour); + } + + @Test + @DisplayName("invaliderCache force un rechargement du repository") + void invaliderCache() { + when(repository.trouverExact(Devise.EUR, Devise.XOF, jour)) + .thenReturn(Optional.of(t(Devise.EUR, Devise.XOF, "655.957", jour))); + + service.convertir(new BigDecimal("1"), Devise.EUR, Devise.XOF, jour); + service.invaliderCache(); + service.convertir(new BigDecimal("1"), Devise.EUR, Devise.XOF, jour); + + verify(repository, times(2)).trouverExact(Devise.EUR, Devise.XOF, jour); + } + + // ── Inputs invalides ────────────────────────────────────────────────── + + @Test + @DisplayName("montant null → IllegalArgumentException") + void montantNull() { + assertThatThrownBy(() -> service.convertir(null, Devise.EUR, Devise.XOF, jour)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("devise null → IllegalArgumentException") + void deviseNull() { + assertThatThrownBy(() -> service.convertir(BigDecimal.ONE, null, Devise.XOF, jour)) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/test/java/dev/lions/unionflow/server/service/diaspora/KycDiasporaServiceTest.java b/src/test/java/dev/lions/unionflow/server/service/diaspora/KycDiasporaServiceTest.java new file mode 100644 index 0000000..2509877 --- /dev/null +++ b/src/test/java/dev/lions/unionflow/server/service/diaspora/KycDiasporaServiceTest.java @@ -0,0 +1,181 @@ +package dev.lions.unionflow.server.service.diaspora; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.lions.unionflow.server.entity.Devise; +import dev.lions.unionflow.server.entity.KycDossier; +import dev.lions.unionflow.server.entity.Membre; +import java.math.BigDecimal; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class KycDiasporaServiceTest { + + private final KycDiasporaService service = new KycDiasporaService(); + + // ── validerCoherence ────────────────────────────────────────────────────── + + @Test + @DisplayName("Membre null → erreur") + void coherence_null() { + assertThat(service.validerCoherence(null)).contains("Membre null"); + } + + @Test + @DisplayName("Diaspora avec passeport et FRA → OK") + void coherence_diasporaValide() { + Membre m = Membre.builder() + .estDiaspora(true) + .paysResidence("FRA") + .numeroPasseport("12AB34567") + .build(); + assertThat(service.validerCoherence(m)).isEmpty(); + } + + @Test + @DisplayName("Diaspora sans passeport → erreur") + void coherence_diasporaSansPasseport() { + Membre m = Membre.builder() + .estDiaspora(true) + .paysResidence("FRA") + .build(); + assertThat(service.validerCoherence(m)) + .anyMatch(e -> e.contains("numero_passeport obligatoire")); + } + + @Test + @DisplayName("Diaspora avec pays UEMOA → erreur") + void coherence_diasporaPaysUemoa() { + Membre m = Membre.builder() + .estDiaspora(true) + .paysResidence("CIV") + .numeroPasseport("CI1234567") + .build(); + assertThat(service.validerCoherence(m)) + .anyMatch(e -> e.contains("ne peut être un pays UEMOA")); + } + + @Test + @DisplayName("Non-diaspora avec pays hors UEMOA → erreur") + void coherence_nonDiasporaPaysEtranger() { + Membre m = Membre.builder() + .estDiaspora(false) + .paysResidence("USA") + .build(); + assertThat(service.validerCoherence(m)) + .anyMatch(e -> e.contains("Incohérence")); + } + + @Test + @DisplayName("Passeport format invalide → erreur") + void coherence_passeportInvalide() { + Membre m = Membre.builder() + .estDiaspora(true) + .paysResidence("FRA") + .numeroPasseport("xx") // trop court + minuscules + .build(); + assertThat(service.validerCoherence(m)) + .anyMatch(e -> e.contains("numero_passeport : format invalide")); + } + + // ── determinerNiveauDueDiligence ───────────────────────────────────────── + + @Test + @DisplayName("PEP → RENFORCE") + void due_pep() { + Membre m = Membre.builder().estDiaspora(false).build(); + KycDossier k = KycDossier.builder().estPep(true).build(); + assertThat(service.determinerNiveauDueDiligence(m, k)).isEqualTo("RENFORCE"); + } + + @Test + @DisplayName("Diaspora France → STANDARD") + void due_diasporaFra() { + Membre m = Membre.builder().estDiaspora(true).paysResidence("FRA").build(); + KycDossier k = KycDossier.builder().estPep(false).build(); + assertThat(service.determinerNiveauDueDiligence(m, k)).isEqualTo("STANDARD"); + } + + @Test + @DisplayName("Diaspora pays grey-list FATF → RENFORCE") + void due_diasporaGreyList() { + Membre m = Membre.builder().estDiaspora(true).paysResidence("PHL").build(); + KycDossier k = KycDossier.builder().estPep(false).build(); + assertThat(service.determinerNiveauDueDiligence(m, k)).isEqualTo("RENFORCE"); + } + + @Test + @DisplayName("Diaspora pays inconnu (ni sécurisé ni grey-list) → RENFORCE") + void due_diasporaPaysInconnu() { + Membre m = Membre.builder().estDiaspora(true).paysResidence("KAZ").build(); + KycDossier k = KycDossier.builder().estPep(false).build(); + assertThat(service.determinerNiveauDueDiligence(m, k)).isEqualTo("RENFORCE"); + } + + @Test + @DisplayName("Résident UEMOA + non PEP → STANDARD") + void due_residentUemoa() { + Membre m = Membre.builder().estDiaspora(false).build(); + KycDossier k = KycDossier.builder().estPep(false).build(); + assertThat(service.determinerNiveauDueDiligence(m, k)).isEqualTo("STANDARD"); + } + + @Test + @DisplayName("Membre ou KYC null → STANDARD défaut") + void due_null() { + assertThat(service.determinerNiveauDueDiligence(null, null)).isEqualTo("STANDARD"); + } + + // ── depasseSeuilAmlInternational ───────────────────────────────────────── + + @Test + @DisplayName("EUR ≥ 1000 → dépasse seuil") + void seuil_eurAuDessus() { + assertThat(service.depasseSeuilAmlInternational(new BigDecimal("1500"), Devise.EUR, null)) + .isTrue(); + } + + @Test + @DisplayName("EUR < 1000 → ne dépasse pas") + void seuil_eurEnDessous() { + assertThat(service.depasseSeuilAmlInternational(new BigDecimal("500"), Devise.EUR, null)) + .isFalse(); + } + + @Test + @DisplayName("USD avec taux fourni — calcul correct") + void seuil_usdAvecTaux() { + // 1500 USD × 0.92 = 1380 EUR ≥ 1000 + assertThat(service.depasseSeuilAmlInternational(new BigDecimal("1500"), Devise.USD, + new BigDecimal("0.92"))).isTrue(); + // 500 USD × 0.92 = 460 EUR < 1000 + assertThat(service.depasseSeuilAmlInternational(new BigDecimal("500"), Devise.USD, + new BigDecimal("0.92"))).isFalse(); + } + + @Test + @DisplayName("Sans taux disponible → considéré dépassé (prudence)") + void seuil_sansTauxPrudence() { + assertThat(service.depasseSeuilAmlInternational(new BigDecimal("100"), Devise.GBP, null)) + .isTrue(); + } + + @Test + @DisplayName("XOF (devise locale) → ne déclenche pas reporting international") + void seuil_xof() { + assertThat(service.depasseSeuilAmlInternational(new BigDecimal("100000000"), + Devise.XOF, null)).isFalse(); + } + + @Test + @DisplayName("Montant null → false") + void seuil_montantNull() { + assertThat(service.depasseSeuilAmlInternational(null, Devise.EUR, null)).isFalse(); + } + + @Test + @DisplayName("Devise null → false") + void seuil_deviseNull() { + assertThat(service.depasseSeuilAmlInternational(BigDecimal.TEN, null, null)).isFalse(); + } +}