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)
216 lines
7.6 KiB
Java
216 lines
7.6 KiB
Java
package dev.lions.unionflow.server.entity;
|
|
|
|
import com.fasterxml.jackson.annotation.JsonIgnore;
|
|
import jakarta.persistence.*;
|
|
import jakarta.validation.constraints.*;
|
|
import java.time.LocalDate;
|
|
import java.util.ArrayList;
|
|
import java.util.List;
|
|
import java.util.UUID;
|
|
import lombok.*;
|
|
|
|
/**
|
|
* Identité globale unique d'un utilisateur UnionFlow.
|
|
*
|
|
* <p>
|
|
* Un utilisateur possède un seul compte sur toute la plateforme.
|
|
* Ses adhésions aux organisations sont gérées dans {@link MembreOrganisation}.
|
|
*
|
|
* <p>
|
|
* Table : {@code utilisateurs}
|
|
*/
|
|
@Entity
|
|
@Table(name = "utilisateurs", indexes = {
|
|
@Index(name = "idx_utilisateur_email", columnList = "email", unique = true),
|
|
@Index(name = "idx_utilisateur_numero", columnList = "numero_membre", unique = true),
|
|
@Index(name = "idx_utilisateur_keycloak", columnList = "keycloak_id", unique = true),
|
|
@Index(name = "idx_utilisateur_actif", columnList = "actif"),
|
|
@Index(name = "idx_utilisateur_statut", columnList = "statut_compte")
|
|
})
|
|
@Data
|
|
@NoArgsConstructor
|
|
@AllArgsConstructor
|
|
@Builder
|
|
@EqualsAndHashCode(callSuper = true)
|
|
public class Membre extends BaseEntity {
|
|
|
|
/** Identifiant Keycloak (UUID du compte OIDC) */
|
|
@Column(name = "keycloak_id", unique = true)
|
|
private UUID keycloakId;
|
|
|
|
/** Numéro de membre — unique globalement sur toute la plateforme */
|
|
@NotBlank
|
|
@Column(name = "numero_membre", unique = true, nullable = false, length = 20)
|
|
private String numeroMembre;
|
|
|
|
@NotBlank
|
|
@Column(name = "prenom", nullable = false, length = 100)
|
|
private String prenom;
|
|
|
|
@NotBlank
|
|
@Column(name = "nom", nullable = false, length = 100)
|
|
private String nom;
|
|
|
|
@Email
|
|
@NotBlank
|
|
@Column(name = "email", unique = true, nullable = false, length = 255)
|
|
private String email;
|
|
|
|
@Column(name = "telephone", length = 20)
|
|
private String telephone;
|
|
|
|
/** Token FCM pour les notifications push Firebase. NULL si l'app mobile n'est pas installée ou si le membre a refusé les notifications. */
|
|
@Column(name = "fcm_token", length = 500)
|
|
private String fcmToken;
|
|
|
|
/**
|
|
* Numéro CMU (Couverture Maladie Universelle) Côte d'Ivoire — auto-déclaré par le membre.
|
|
*
|
|
* <p>Obligatoire pour les organisations de type {@code MUTUELLE_SANTE} (Loi 2014-131
|
|
* exige enrôlement CMU comme préalable à toute mutuelle complémentaire). Format CNAM :
|
|
* 11 caractères alphanumériques. La vérification de la validité se fait manuellement
|
|
* (admin) faute d'API publique CNAM disponible au 2026-04-25.
|
|
*
|
|
* @since 2026-04-25 — passage CMU à cotisation obligatoire 1er jan 2026
|
|
*/
|
|
@Pattern(regexp = "^[A-Z0-9]{11}$|^$", message = "Le numéro CMU doit faire 11 caractères alphanumériques majuscules")
|
|
@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;
|
|
|
|
@NotNull
|
|
@Column(name = "date_naissance", nullable = false)
|
|
private LocalDate dateNaissance;
|
|
|
|
@Column(name = "profession", length = 100)
|
|
private String profession;
|
|
|
|
@Column(name = "photo_url", length = 500)
|
|
private String photoUrl;
|
|
|
|
@Builder.Default
|
|
@Column(name = "statut_compte", nullable = false, length = 30)
|
|
private String statutCompte = "EN_ATTENTE_VALIDATION";
|
|
|
|
/** Vrai si le membre n'a jamais changé son mot de passe généré par l'admin. */
|
|
@Builder.Default
|
|
@Column(name = "premiere_connexion", nullable = false)
|
|
private Boolean premiereConnexion = true;
|
|
|
|
/**
|
|
* Statut matrimonial (domaine
|
|
* {@code STATUT_MATRIMONIAL} dans
|
|
* {@code types_reference}).
|
|
*/
|
|
@Column(name = "statut_matrimonial", length = 50)
|
|
private String statutMatrimonial;
|
|
|
|
/** Nationalité. */
|
|
@Column(name = "nationalite", length = 100)
|
|
private String nationalite;
|
|
|
|
/**
|
|
* Type de pièce d'identité (domaine
|
|
* {@code TYPE_IDENTITE} dans
|
|
* {@code types_reference}).
|
|
*/
|
|
@Column(name = "type_identite", length = 50)
|
|
private String typeIdentite;
|
|
|
|
/** Numéro de la pièce d'identité. */
|
|
@Column(name = "numero_identite", length = 100)
|
|
private String numeroIdentite;
|
|
|
|
/** Notes / biographie libre du membre. */
|
|
@Column(name = "notes", length = 1000)
|
|
private String notes;
|
|
|
|
/** Niveau de vigilance KYC LCB-FT (SIMPLIFIE, RENFORCE). */
|
|
@Column(name = "niveau_vigilance_kyc", length = 20)
|
|
private String niveauVigilanceKyc;
|
|
|
|
/** Statut de vérification d'identité (NON_VERIFIE, EN_COURS, VERIFIE, REFUSE). */
|
|
@Column(name = "statut_kyc", length = 20)
|
|
private String statutKyc;
|
|
|
|
/** Date de dernière vérification d'identité. */
|
|
@Column(name = "date_verification_identite")
|
|
private LocalDate dateVerificationIdentite;
|
|
|
|
// ── Relations ────────────────────────────────────────────────────────────
|
|
|
|
/** Adhésions à des organisations */
|
|
@JsonIgnore
|
|
@OneToMany(mappedBy = "membre", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
|
|
@Builder.Default
|
|
private List<MembreOrganisation> membresOrganisations = new ArrayList<>();
|
|
|
|
@JsonIgnore
|
|
@OneToMany(mappedBy = "membre", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
|
|
@Builder.Default
|
|
private List<Adresse> adresses = new ArrayList<>();
|
|
|
|
@JsonIgnore
|
|
@OneToMany(mappedBy = "membre", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
|
|
@Builder.Default
|
|
private List<CompteWave> comptesWave = new ArrayList<>();
|
|
|
|
@JsonIgnore
|
|
@OneToMany(mappedBy = "membre", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
|
|
@Builder.Default
|
|
private List<Paiement> paiements = new ArrayList<>();
|
|
|
|
// ── Méthodes métier ───────────────────────────────────────────────────────
|
|
|
|
public String getNomComplet() {
|
|
return prenom + " " + nom;
|
|
}
|
|
|
|
public boolean isMajeur() {
|
|
return dateNaissance != null && dateNaissance.isBefore(LocalDate.now().minusYears(18));
|
|
}
|
|
|
|
public int getAge() {
|
|
return dateNaissance != null ? LocalDate.now().getYear() - dateNaissance.getYear() : 0;
|
|
}
|
|
|
|
@PrePersist
|
|
protected void onCreate() {
|
|
super.onCreate();
|
|
if (statutCompte == null) {
|
|
statutCompte = "EN_ATTENTE_VALIDATION";
|
|
}
|
|
}
|
|
}
|