fix(disaster-recovery): restaurer 134 fichiers accidentellement supprimés par a72ab54
Some checks failed
CI/CD Pipeline / pipeline (push) Failing after 3m46s

Le commit a72ab54 (chore docker Dockerfile racine) a involontairement balayé
des fichiers du commit 31330d9 (PI-SPI, KYC, RLS, mutuelle parts, comptabilité
PDF) lors d'un git add -A trop large.

Restauration de l'intégralité des fichiers depuis a72ab54^ :
- 11 migrations Flyway V32-V42 (parts sociales, SYSCOHADA, Keycloak Org Id, KYC, RLS, Provider défaut, FCM, App DB Roles)
- Package payment/pispi/ complet (PispiAuth, PispiClient, PispiIso20022Mapper, PispiSignatureVerifier, PispiWebhookResource, dto/Pacs008Request, dto/Pacs002Response, PispiPaymentProvider)
- Package payment/{wave,orangemoney,mtnmomo}/* (PaymentProvider impls)
- Package payment/orchestration/ (PaymentOrchestrator, PaymentProviderRegistry)
- Entités KycDossier, mutuelle/parts/* (ComptePartsSociales, TransactionPartsSociales)
- Mappers, repositories, resources associés
- Services KycAmlService, ComptabilitePdfService, ReleveComptePdfService, InteretsEpargneService
- AdminKeycloakOrganisationResource, KycResource, PaiementUnifieResource
- Tests unitaires PI-SPI, KYC, mutuelle parts
This commit is contained in:
2026-04-25 01:00:03 +00:00
parent b434282000
commit 044ca4bd7e
134 changed files with 22512 additions and 0 deletions

View File

@@ -0,0 +1,112 @@
package dev.lions.unionflow.server.entity;
import dev.lions.unionflow.server.api.enums.membre.NiveauRisqueKyc;
import dev.lions.unionflow.server.api.enums.membre.StatutKyc;
import dev.lions.unionflow.server.api.enums.membre.TypePieceIdentite;
import jakarta.persistence.*;
import jakarta.validation.constraints.*;
import lombok.*;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.UUID;
/**
* Dossier KYC/AML d'un membre — conformité GIABA/BCEAO LCB-FT.
*
* <p>Rétention 10 ans requise par le GIABA. La colonne {@code anneeReference}
* sert à l'archivage logique par année (partitionnement futur PostgreSQL).
*
* <p>Un seul dossier actif ({@code actif=true}) par membre à la fois.
* Les dossiers expirés ou archivés ont {@code actif=false}.
*/
@Entity
@Table(
name = "kyc_dossier",
indexes = {
@Index(name = "idx_kyc_membre_id", columnList = "membre_id"),
@Index(name = "idx_kyc_statut", columnList = "statut"),
@Index(name = "idx_kyc_niveau_risque", columnList = "niveau_risque"),
@Index(name = "idx_kyc_annee", columnList = "annee_reference")
}
)
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@EqualsAndHashCode(callSuper = true)
public class KycDossier extends BaseEntity {
@NotNull
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "membre_id", nullable = false)
private Membre membre;
@NotNull
@Enumerated(EnumType.STRING)
@Column(name = "type_piece", nullable = false, length = 30)
private TypePieceIdentite typePiece;
@NotBlank
@Size(max = 50)
@Column(name = "numero_piece", nullable = false, length = 50)
private String numeroPiece;
@Column(name = "date_expiration_piece")
private LocalDate dateExpirationPiece;
@Size(max = 500)
@Column(name = "piece_identite_recto_file_id", length = 500)
private String pieceIdentiteRectoFileId;
@Size(max = 500)
@Column(name = "piece_identite_verso_file_id", length = 500)
private String pieceIdentiteVersoFileId;
@Size(max = 500)
@Column(name = "justif_domicile_file_id", length = 500)
private String justifDomicileFileId;
@NotNull
@Enumerated(EnumType.STRING)
@Column(name = "statut", nullable = false, length = 20)
@Builder.Default
private StatutKyc statut = StatutKyc.NON_VERIFIE;
@NotNull
@Enumerated(EnumType.STRING)
@Column(name = "niveau_risque", nullable = false, length = 20)
@Builder.Default
private NiveauRisqueKyc niveauRisque = NiveauRisqueKyc.FAIBLE;
@Min(0) @Max(100)
@Column(name = "score_risque", nullable = false)
@Builder.Default
private int scoreRisque = 0;
@Builder.Default
@Column(name = "est_pep", nullable = false)
private boolean estPep = false;
@Size(max = 5)
@Column(name = "nationalite", length = 5)
private String nationalite;
@Column(name = "date_verification")
private LocalDateTime dateVerification;
@Column(name = "validateur_id")
private UUID validateurId;
@Size(max = 1000)
@Column(name = "notes_validateur", length = 1000)
private String notesValidateur;
@Column(name = "annee_reference", nullable = false)
@Builder.Default
private int anneeReference = java.time.LocalDate.now().getYear();
public boolean isPieceExpiree() {
return dateExpirationPiece != null && dateExpirationPiece.isBefore(LocalDate.now());
}
}

View File

@@ -0,0 +1,68 @@
package dev.lions.unionflow.server.entity.mutuelle;
import dev.lions.unionflow.server.entity.BaseEntity;
import dev.lions.unionflow.server.entity.Organisation;
import jakarta.persistence.*;
import jakarta.validation.constraints.NotNull;
import lombok.*;
import java.math.BigDecimal;
import java.time.LocalDate;
@Entity
@Table(name = "parametres_financiers_mutuelle", indexes = {
@Index(name = "idx_pfm_org", columnList = "organisation_id", unique = true)
})
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@EqualsAndHashCode(callSuper = true)
public class ParametresFinanciersMutuelle extends BaseEntity {
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "organisation_id", nullable = false, unique = true)
private Organisation organisation;
/** Valeur nominale par défaut d'une part sociale */
@NotNull
@Column(name = "valeur_nominale_par_defaut", nullable = false, precision = 19, scale = 4)
@Builder.Default
private BigDecimal valeurNominaleParDefaut = new BigDecimal("5000");
/** Taux d'intérêt annuel sur l'épargne, ex: 0.03 = 3% */
@NotNull
@Column(name = "taux_interet_annuel_epargne", nullable = false, precision = 6, scale = 4)
@Builder.Default
private BigDecimal tauxInteretAnnuelEpargne = new BigDecimal("0.03");
/** Taux de dividende annuel sur les parts sociales, ex: 0.05 = 5% */
@NotNull
@Column(name = "taux_dividende_parts_annuel", nullable = false, precision = 6, scale = 4)
@Builder.Default
private BigDecimal tauxDividendePartsAnnuel = new BigDecimal("0.05");
/** MENSUEL | TRIMESTRIEL | ANNUEL */
@NotNull
@Column(name = "periodicite_calcul", nullable = false, length = 20)
@Builder.Default
private String periodiciteCalcul = "MENSUEL";
/** Solde minimum en dessous duquel les intérêts ne s'appliquent pas */
@Column(name = "seuil_min_epargne_interets", precision = 19, scale = 4)
@Builder.Default
private BigDecimal seuilMinEpargneInterets = BigDecimal.ZERO;
/** Date du prochain calcul planifié */
@Column(name = "prochaine_calcul_interets")
private LocalDate prochaineCalculInterets;
/** Date du dernier calcul effectué */
@Column(name = "dernier_calcul_interets")
private LocalDate dernierCalculInterets;
/** Nombre de comptes traités lors du dernier calcul */
@Column(name = "dernier_nb_comptes_traites")
@Builder.Default
private Integer dernierNbComptesTraites = 0;
}

View File

@@ -0,0 +1,78 @@
package dev.lions.unionflow.server.entity.mutuelle.parts;
import dev.lions.unionflow.server.api.enums.mutuelle.parts.StatutComptePartsSociales;
import dev.lions.unionflow.server.entity.BaseEntity;
import dev.lions.unionflow.server.entity.Membre;
import dev.lions.unionflow.server.entity.Organisation;
import jakarta.persistence.*;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.*;
import java.math.BigDecimal;
import java.time.LocalDate;
@Entity
@Table(name = "comptes_parts_sociales", indexes = {
@Index(name = "idx_cps_numero", columnList = "numero_compte", unique = true),
@Index(name = "idx_cps_membre", columnList = "membre_id"),
@Index(name = "idx_cps_org", columnList = "organisation_id")
})
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@EqualsAndHashCode(callSuper = true)
public class ComptePartsSociales extends BaseEntity {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "membre_id", nullable = false)
private Membre membre;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "organisation_id", nullable = false)
private Organisation organisation;
@NotBlank
@Column(name = "numero_compte", unique = true, nullable = false, length = 50)
private String numeroCompte;
@NotNull
@Min(0)
@Column(name = "nombre_parts", nullable = false)
@Builder.Default
private Integer nombreParts = 0;
@NotNull
@Column(name = "valeur_nominale", nullable = false, precision = 19, scale = 4)
private BigDecimal valeurNominale;
/** nombreParts × valeurNominale — mis à jour à chaque transaction */
@NotNull
@Column(name = "montant_total", nullable = false, precision = 19, scale = 4)
@Builder.Default
private BigDecimal montantTotal = BigDecimal.ZERO;
@NotNull
@Column(name = "total_dividendes_recus", nullable = false, precision = 19, scale = 4)
@Builder.Default
private BigDecimal totalDividendesRecus = BigDecimal.ZERO;
@NotNull
@Enumerated(EnumType.STRING)
@Column(name = "statut", nullable = false, length = 30)
@Builder.Default
private StatutComptePartsSociales statut = StatutComptePartsSociales.ACTIF;
@NotNull
@Column(name = "date_ouverture", nullable = false)
@Builder.Default
private LocalDate dateOuverture = LocalDate.now();
@Column(name = "date_derniere_operation")
private LocalDate dateDerniereOperation;
@Column(name = "notes", length = 500)
private String notes;
}

View File

@@ -0,0 +1,61 @@
package dev.lions.unionflow.server.entity.mutuelle.parts;
import dev.lions.unionflow.server.api.enums.mutuelle.parts.TypeTransactionPartsSociales;
import dev.lions.unionflow.server.entity.BaseEntity;
import jakarta.persistence.*;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import lombok.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Entity
@Table(name = "transactions_parts_sociales", indexes = {
@Index(name = "idx_tps_compte", columnList = "compte_id"),
@Index(name = "idx_tps_date", columnList = "date_transaction")
})
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@EqualsAndHashCode(callSuper = true)
public class TransactionPartsSociales extends BaseEntity {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "compte_id", nullable = false)
private ComptePartsSociales compte;
@NotNull
@Enumerated(EnumType.STRING)
@Column(name = "type_transaction", nullable = false, length = 50)
private TypeTransactionPartsSociales typeTransaction;
@NotNull
@Min(1)
@Column(name = "nombre_parts", nullable = false)
private Integer nombreParts;
@NotNull
@Column(name = "montant", nullable = false, precision = 19, scale = 4)
private BigDecimal montant;
@Column(name = "solde_parts_avant", nullable = false)
@Builder.Default
private Integer soldePartsAvant = 0;
@Column(name = "solde_parts_apres", nullable = false)
@Builder.Default
private Integer soldePartsApres = 0;
@Column(name = "motif", length = 500)
private String motif;
@Column(name = "reference_externe", length = 100)
private String referenceExterne;
@NotNull
@Column(name = "date_transaction", nullable = false)
@Builder.Default
private LocalDateTime dateTransaction = LocalDateTime.now();
}

View File

@@ -0,0 +1,15 @@
package dev.lions.unionflow.server.mapper.mutuelle.parts;
import dev.lions.unionflow.server.api.dto.mutuelle.parts.ComptePartsSocialesResponse;
import dev.lions.unionflow.server.entity.mutuelle.parts.ComptePartsSociales;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
@Mapper(componentModel = "cdi", builder = @org.mapstruct.Builder(disableBuilder = true))
public interface ComptePartsSocialesMapper {
@Mapping(target = "membreId", expression = "java(entity.getMembre() != null ? entity.getMembre().getId().toString() : null)")
@Mapping(target = "membreNomComplet", expression = "java(entity.getMembre() != null ? entity.getMembre().getNom() + ' ' + entity.getMembre().getPrenom() : null)")
@Mapping(target = "organisationId", expression = "java(entity.getOrganisation() != null ? entity.getOrganisation().getId().toString() : null)")
ComptePartsSocialesResponse toDto(ComptePartsSociales entity);
}

View File

@@ -0,0 +1,15 @@
package dev.lions.unionflow.server.mapper.mutuelle.parts;
import dev.lions.unionflow.server.api.dto.mutuelle.parts.TransactionPartsSocialesResponse;
import dev.lions.unionflow.server.entity.mutuelle.parts.TransactionPartsSociales;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
@Mapper(componentModel = "cdi", builder = @org.mapstruct.Builder(disableBuilder = true))
public interface TransactionPartsSocialesMapper {
@Mapping(target = "compteId", expression = "java(entity.getCompte() != null ? entity.getCompte().getId().toString() : null)")
@Mapping(target = "numeroCompte", expression = "java(entity.getCompte() != null ? entity.getCompte().getNumeroCompte() : null)")
@Mapping(target = "typeTransactionLibelle", expression = "java(entity.getTypeTransaction() != null ? entity.getTypeTransaction().getLibelle() : null)")
TransactionPartsSocialesResponse toDto(TransactionPartsSociales entity);
}

View File

@@ -0,0 +1,71 @@
package dev.lions.unionflow.server.payment.mtnmomo;
import dev.lions.unionflow.server.api.payment.*;
import jakarta.enterprise.context.ApplicationScoped;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import java.time.Instant;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
/**
* Provider MTN MoMo (stub — à implémenter avec l'API MTN Mobile Money).
*
* <p>Sandbox : https://sandbox.momodeveloper.mtn.com
* Requis : subscription-key, api-user, api-key (via provisioning sandbox).
*/
@Slf4j
@ApplicationScoped
public class MtnMomoPaymentProvider implements PaymentProvider {
public static final String CODE = "MTN_MOMO";
@ConfigProperty(name = "mtnmomo.collection.subscription-key")
Optional<String> subscriptionKeyOpt;
@ConfigProperty(name = "mtnmomo.api.base-url", defaultValue = "https://sandbox.momodeveloper.mtn.com")
String baseUrl;
String subscriptionKey;
@jakarta.annotation.PostConstruct
void init() {
subscriptionKey = subscriptionKeyOpt.orElse("");
}
@Override
public String getProviderCode() {
return CODE;
}
@Override
public CheckoutSession initiateCheckout(CheckoutRequest request) throws PaymentException {
if (subscriptionKey == null || subscriptionKey.isBlank()) {
log.warn("MTN MoMo non configuré — mode mock actif pour ref={}", request.reference());
String mockId = "MTN-MOCK-" + UUID.randomUUID().toString().substring(0, 8).toUpperCase();
return new CheckoutSession(mockId, "https://mock.mtn.ci/pay/" + mockId,
Instant.now().plusSeconds(600), Map.of("mock", "true", "provider", CODE));
}
// TODO P1.3 Phase 3 : implémenter MTN Collection API (requestToPay)
throw new PaymentException(CODE, "MTN MoMo non encore implémenté en production", 501);
}
@Override
public PaymentStatus getStatus(String externalId) throws PaymentException {
log.warn("MTN MoMo getStatus mock pour externalId={}", externalId);
return PaymentStatus.PROCESSING;
}
@Override
public PaymentEvent processWebhook(String rawBody, Map<String, String> headers) throws PaymentException {
// TODO P1.3 Phase 3 : parser callback MTN MoMo
throw new PaymentException(CODE, "Webhook MTN MoMo non encore implémenté", 501);
}
@Override
public boolean isAvailable() {
return subscriptionKey != null && !subscriptionKey.isBlank();
}
}

View File

@@ -0,0 +1,73 @@
package dev.lions.unionflow.server.payment.orangemoney;
import dev.lions.unionflow.server.api.payment.*;
import jakarta.enterprise.context.ApplicationScoped;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import java.time.Instant;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
/**
* Provider Orange Money (stub — à implémenter avec l'API Orange Money WebPay).
*
* <p>Sandbox : https://developer.orange.com/apis/om-webpay
* Requis : client_id, client_secret, merchant_key par pays.
*
* <p>Retourne un mock tant que {@code orange.api.client-id} n'est pas configuré.
*/
@Slf4j
@ApplicationScoped
public class OrangeMoneyPaymentProvider implements PaymentProvider {
public static final String CODE = "ORANGE_MONEY";
@ConfigProperty(name = "orange.api.client-id")
Optional<String> clientIdOpt;
@ConfigProperty(name = "orange.api.base-url", defaultValue = "https://api.orange.com/orange-money-webpay/dev/v1")
String baseUrl;
String clientId;
@jakarta.annotation.PostConstruct
void init() {
clientId = clientIdOpt.orElse("");
}
@Override
public String getProviderCode() {
return CODE;
}
@Override
public CheckoutSession initiateCheckout(CheckoutRequest request) throws PaymentException {
if (clientId == null || clientId.isBlank()) {
log.warn("Orange Money non configuré — mode mock actif pour ref={}", request.reference());
String mockId = "OM-MOCK-" + UUID.randomUUID().toString().substring(0, 8).toUpperCase();
return new CheckoutSession(mockId, "https://mock.orange.ci/pay/" + mockId,
Instant.now().plusSeconds(900), Map.of("mock", "true", "provider", CODE));
}
// TODO P1.3 Phase 3 : implémenter OAuth2 + POST /webpay
throw new PaymentException(CODE, "Orange Money non encore implémenté en production", 501);
}
@Override
public PaymentStatus getStatus(String externalId) throws PaymentException {
log.warn("Orange Money getStatus mock pour externalId={}", externalId);
return PaymentStatus.PROCESSING;
}
@Override
public PaymentEvent processWebhook(String rawBody, Map<String, String> headers) throws PaymentException {
// TODO P1.3 Phase 3 : parser webhook Orange Money + vérifier signature
throw new PaymentException(CODE, "Webhook Orange Money non encore implémenté", 501);
}
@Override
public boolean isAvailable() {
return clientId != null && !clientId.isBlank();
}
}

View File

@@ -0,0 +1,93 @@
package dev.lions.unionflow.server.payment.orchestration;
import dev.lions.unionflow.server.api.payment.*;
import dev.lions.unionflow.server.service.PaiementService;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import java.util.List;
/**
* Façade de paiement avec stratégie de fallback automatique.
*
* <p>Ordre de priorité :
* <ol>
* <li>PI-SPI si disponible (obligation réglementaire BCEAO)</li>
* <li>Provider demandé par le client</li>
* <li>Wave (provider par défaut)</li>
* </ol>
*/
@Slf4j
@ApplicationScoped
public class PaymentOrchestrator {
@Inject
PaymentProviderRegistry registry;
@Inject
PaiementService paiementService;
@ConfigProperty(name = "payment.default-provider", defaultValue = "WAVE")
String defaultProvider;
@ConfigProperty(name = "payment.pispi-priority", defaultValue = "false")
boolean pispiPriority;
/**
* Lance un checkout sur le provider demandé, avec fallback si indisponible.
*
* @param request la requête de checkout
* @param providerCode le provider demandé (null = provider par défaut)
*/
public CheckoutSession initierPaiement(CheckoutRequest request, String providerCode) throws PaymentException {
List<String> ordre = buildProviderOrder(providerCode);
PaymentException dernierEchec = null;
for (String code : ordre) {
PaymentProvider provider = tryGetProvider(code);
if (provider == null || !provider.isAvailable()) continue;
try {
CheckoutSession session = provider.initiateCheckout(request);
log.info("Checkout initié via {} pour ref={}", code, request.reference());
return session;
} catch (PaymentException e) {
log.warn("Provider {} échoué pour ref={}: {} — tentative fallback",
code, request.reference(), e.getMessage());
dernierEchec = e;
}
}
throw dernierEchec != null ? dernierEchec
: new PaymentException("NONE", "Aucun provider de paiement disponible", 503);
}
/**
* Traite un événement de paiement reçu via webhook.
* Délègue la mise à jour métier (souscription, cotisation...) selon la référence.
*/
public void handleEvent(PaymentEvent event) {
log.info("PaymentEvent reçu : externalId={}, ref={}, statut={}",
event.externalId(), event.reference(), event.status());
paiementService.mettreAJourStatutDepuisWebhook(event);
}
private List<String> buildProviderOrder(String requested) {
if (pispiPriority) {
if (requested != null) return List.of("PISPI", requested, defaultProvider);
return List.of("PISPI", defaultProvider);
}
if (requested != null) return List.of(requested, defaultProvider);
return List.of(defaultProvider);
}
private PaymentProvider tryGetProvider(String code) {
try {
return registry.get(code);
} catch (UnsupportedOperationException e) {
return null;
}
}
}

View File

@@ -0,0 +1,47 @@
package dev.lions.unionflow.server.payment.orchestration;
import dev.lions.unionflow.server.api.payment.PaymentProvider;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.inject.Any;
import jakarta.enterprise.inject.Instance;
import jakarta.inject.Inject;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.StreamSupport;
/**
* Registry CDI des providers de paiement disponibles.
* Résout dynamiquement le bon provider par son code.
*/
@ApplicationScoped
public class PaymentProviderRegistry {
@Inject
@Any
Instance<PaymentProvider> providers;
/**
* Retourne le provider identifié par {@code code}.
*
* @throws UnsupportedOperationException si aucun provider n'est enregistré pour ce code
*/
public PaymentProvider get(String code) {
return StreamSupport.stream(providers.spliterator(), false)
.filter(p -> p.getProviderCode().equalsIgnoreCase(code))
.findFirst()
.orElseThrow(() -> new UnsupportedOperationException(
"Provider de paiement non supporté : " + code));
}
/** Retourne tous les providers disponibles. */
public List<PaymentProvider> getAll() {
return StreamSupport.stream(providers.spliterator(), false)
.collect(Collectors.toList());
}
/** Retourne les codes de tous les providers disponibles. */
public List<String> getAvailableCodes() {
return getAll().stream().map(PaymentProvider::getProviderCode).collect(Collectors.toList());
}
}

View File

@@ -0,0 +1,83 @@
package dev.lions.unionflow.server.payment.pispi;
import dev.lions.unionflow.server.api.payment.PaymentException;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.json.Json;
import jakarta.json.JsonObject;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import java.io.StringReader;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Optional;
@Slf4j
@ApplicationScoped
public class PispiAuth {
@ConfigProperty(name = "pispi.api.client-id")
Optional<String> clientIdOpt;
@ConfigProperty(name = "pispi.api.client-secret")
Optional<String> clientSecretOpt;
String clientId;
String clientSecret;
@jakarta.annotation.PostConstruct
void init() {
clientId = clientIdOpt.orElse("");
clientSecret = clientSecretOpt.orElse("");
}
@ConfigProperty(name = "pispi.api.base-url", defaultValue = "https://sandbox.pispi.bceao.int/business-api/v1")
String baseUrl;
private String cachedToken;
private Instant cacheExpiry;
public synchronized String getAccessToken() throws PaymentException {
if (cachedToken != null && Instant.now().isBefore(cacheExpiry)) {
return cachedToken;
}
try {
String body = "grant_type=client_credentials"
+ "&client_id=" + URLEncoder.encode(clientId, StandardCharsets.UTF_8)
+ "&client_secret=" + URLEncoder.encode(clientSecret, StandardCharsets.UTF_8)
+ "&scope=pispi.transactions";
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "/oauth2/token"))
.header("Content-Type", "application/x-www-form-urlencoded")
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
HttpResponse<String> response = HttpClient.newHttpClient()
.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() >= 400) {
throw new PaymentException("PISPI",
"Erreur OAuth2 PI-SPI HTTP " + response.statusCode() + " : " + response.body(),
503);
}
JsonObject json = Json.createReader(new StringReader(response.body())).readObject();
cachedToken = json.getString("access_token");
int expiresIn = json.getInt("expires_in", 3600);
cacheExpiry = Instant.now().plusSeconds(expiresIn - 60);
log.debug("Token PI-SPI obtenu, expire dans {}s", expiresIn - 60);
return cachedToken;
} catch (PaymentException e) {
throw e;
} catch (Exception e) {
throw new PaymentException("PISPI", "Erreur OAuth2 PI-SPI : " + e.getMessage(), 503, e);
}
}
}

View File

@@ -0,0 +1,96 @@
package dev.lions.unionflow.server.payment.pispi;
import dev.lions.unionflow.server.api.payment.PaymentException;
import dev.lions.unionflow.server.payment.pispi.dto.Pacs002Response;
import dev.lions.unionflow.server.payment.pispi.dto.Pacs008Request;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.Optional;
@Slf4j
@ApplicationScoped
public class PispiClient {
@Inject
PispiAuth pispiAuth;
@ConfigProperty(name = "pispi.api.base-url", defaultValue = "https://sandbox.pispi.bceao.int/business-api/v1")
String baseUrl;
@ConfigProperty(name = "pispi.institution.code")
Optional<String> institutionCodeOpt;
String institutionCode;
@jakarta.annotation.PostConstruct
void init() {
institutionCode = institutionCodeOpt.orElse("");
}
public Pacs002Response initiatePayment(Pacs008Request request) throws PaymentException {
try {
String token = pispiAuth.getAccessToken();
String xmlBody = request.toXml();
log.debug("PI-SPI initiatePayment endToEndId={}", request.getEndToEndId());
HttpRequest httpRequest = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "/transactions/initiate"))
.header("Content-Type", "application/xml")
.header("Authorization", "Bearer " + token)
.header("X-Institution-Code", institutionCode)
.POST(HttpRequest.BodyPublishers.ofString(xmlBody))
.build();
HttpResponse<String> response = HttpClient.newHttpClient()
.send(httpRequest, HttpResponse.BodyHandlers.ofString());
int status = response.statusCode();
if (status >= 400) {
throw new PaymentException("PISPI", "Erreur PI-SPI HTTP " + status, status);
}
return Pacs002Response.fromXml(response.body());
} catch (PaymentException e) {
throw e;
} catch (Exception e) {
throw new PaymentException("PISPI", "Erreur lors de l'initiation du paiement PI-SPI : " + e.getMessage(), 503, e);
}
}
public Pacs002Response getStatus(String transactionId) throws PaymentException {
try {
String token = pispiAuth.getAccessToken();
log.debug("PI-SPI getStatus transactionId={}", transactionId);
HttpRequest httpRequest = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "/transactions/" + transactionId))
.header("Authorization", "Bearer " + token)
.header("X-Institution-Code", institutionCode)
.GET()
.build();
HttpResponse<String> response = HttpClient.newHttpClient()
.send(httpRequest, HttpResponse.BodyHandlers.ofString());
int status = response.statusCode();
if (status >= 400) {
throw new PaymentException("PISPI", "Erreur PI-SPI HTTP " + status, status);
}
return Pacs002Response.fromXml(response.body());
} catch (PaymentException e) {
throw e;
} catch (Exception e) {
throw new PaymentException("PISPI", "Erreur lors de la récupération du statut PI-SPI : " + e.getMessage(), 503, e);
}
}
}

View File

@@ -0,0 +1,70 @@
package dev.lions.unionflow.server.payment.pispi;
import dev.lions.unionflow.server.api.payment.CheckoutRequest;
import dev.lions.unionflow.server.api.payment.PaymentEvent;
import dev.lions.unionflow.server.api.payment.PaymentStatus;
import dev.lions.unionflow.server.payment.pispi.dto.Pacs002Response;
import dev.lions.unionflow.server.payment.pispi.dto.Pacs008Request;
import jakarta.enterprise.context.ApplicationScoped;
import java.time.Instant;
import java.time.format.DateTimeFormatter;
import java.util.UUID;
@ApplicationScoped
public class PispiIso20022Mapper {
public Pacs008Request toPacs008(CheckoutRequest req, String institutionBic) {
Pacs008Request pacs = new Pacs008Request();
pacs.setMessageId("UFMSG-" + UUID.randomUUID().toString().replace("-", "").substring(0, 16).toUpperCase());
pacs.setCreationDateTime(DateTimeFormatter.ISO_INSTANT.format(Instant.now()));
pacs.setNumberOfTransactions("1");
// ISO 20022 : EndToEndId max 35 chars
String ref = req.reference();
pacs.setEndToEndId(ref.length() > 35 ? ref.substring(0, 35) : ref);
pacs.setInstrId("UFINS-" + UUID.randomUUID().toString().substring(0, 8).toUpperCase());
pacs.setAmount(req.amount());
pacs.setCurrency(req.currency());
String customerName = req.metadata() != null
? req.metadata().getOrDefault("customerName", "MEMBRE UNIONFLOW")
: "MEMBRE UNIONFLOW";
pacs.setDebtorName(customerName);
pacs.setDebtorBic(institutionBic);
String creditorName = req.metadata() != null
? req.metadata().getOrDefault("creditorName", "ORGANISATION UNIONFLOW")
: "ORGANISATION UNIONFLOW";
pacs.setCreditorName(creditorName);
pacs.setCreditorBic(institutionBic);
// ISO 20022 : RemittanceInfo max 140 chars
pacs.setRemittanceInfo(ref.length() > 140 ? ref.substring(0, 140) : ref);
return pacs;
}
public PaymentStatus fromPacs002Status(String isoCode) {
return switch (isoCode) {
case "ACSC" -> PaymentStatus.SUCCESS;
case "ACSP" -> PaymentStatus.PROCESSING;
case "RJCT" -> PaymentStatus.FAILED;
case "PDNG" -> PaymentStatus.INITIATED;
default -> PaymentStatus.PROCESSING;
};
}
public PaymentEvent fromPacs002(Pacs002Response resp) {
return new PaymentEvent(
resp.getClearingSystemReference(),
resp.getOriginalEndToEndId(),
fromPacs002Status(resp.getTransactionStatus()),
null,
resp.getClearingSystemReference(),
resp.getAcceptanceDateTime() != null ? resp.getAcceptanceDateTime() : Instant.now()
);
}
}

View File

@@ -0,0 +1,114 @@
package dev.lions.unionflow.server.payment.pispi;
import dev.lions.unionflow.server.api.payment.CheckoutRequest;
import dev.lions.unionflow.server.api.payment.CheckoutSession;
import dev.lions.unionflow.server.api.payment.PaymentEvent;
import dev.lions.unionflow.server.api.payment.PaymentException;
import dev.lions.unionflow.server.api.payment.PaymentProvider;
import dev.lions.unionflow.server.api.payment.PaymentStatus;
import dev.lions.unionflow.server.payment.pispi.dto.Pacs002Response;
import dev.lions.unionflow.server.payment.pispi.dto.Pacs008Request;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import java.time.Instant;
import java.util.Map;
import java.util.UUID;
/**
* Provider PI-SPI BCEAO — interopérabilité paiements instantanés UEMOA.
*
* <p>Sandbox : https://developer.pispi.bceao.int
* Spec : Business API ISO 20022 pacs.008.001.10 / pacs.002.001.14
* Deadline obligation réglementaire : 30 juin 2026
*
* <p>Mode mock automatique si {@code pispi.api.client-id} ou {@code pispi.institution.code} sont absents.
*/
@Slf4j
@ApplicationScoped
public class PispiPaymentProvider implements PaymentProvider {
public static final String CODE = "PISPI";
@Inject
PispiClient pispiClient;
@Inject
PispiIso20022Mapper mapper;
@ConfigProperty(name = "pispi.api.client-id")
java.util.Optional<String> clientIdOpt;
@ConfigProperty(name = "pispi.institution.code")
java.util.Optional<String> institutionCodeOpt;
@ConfigProperty(name = "pispi.institution.bic", defaultValue = "")
String institutionBic;
String clientId;
String institutionCode;
@jakarta.annotation.PostConstruct
void init() {
clientId = clientIdOpt.orElse("");
institutionCode = institutionCodeOpt.orElse("");
}
@Override
public String getProviderCode() {
return CODE;
}
@Override
public CheckoutSession initiateCheckout(CheckoutRequest request) throws PaymentException {
if (!isConfigured()) {
String mockId = "PISPI-MOCK-" + UUID.randomUUID().toString().substring(0, 8).toUpperCase();
log.warn("PI-SPI non configuré — mode mock pour ref={}", request.reference());
return new CheckoutSession(
mockId,
"https://mock.pispi.bceao.int/pay/" + mockId,
Instant.now().plusSeconds(1800),
Map.of("mock", "true", "provider", CODE)
);
}
Pacs008Request pacs008 = mapper.toPacs008(request, institutionBic);
Pacs002Response pacs002 = pispiClient.initiatePayment(pacs008);
String externalId = pacs002.getClearingSystemReference() != null
? pacs002.getClearingSystemReference()
: pacs008.getEndToEndId();
return new CheckoutSession(
externalId,
null,
Instant.now().plusSeconds(1800),
Map.of("provider", CODE, "iso", "pacs.008.001.10", "endToEndId", pacs008.getEndToEndId())
);
}
@Override
public PaymentStatus getStatus(String externalId) throws PaymentException {
if (!isConfigured()) {
log.warn("PI-SPI non configuré — getStatus mock pour id={}", externalId);
return PaymentStatus.PROCESSING;
}
Pacs002Response pacs002 = pispiClient.getStatus(externalId);
return mapper.fromPacs002Status(pacs002.getTransactionStatus());
}
@Override
public PaymentEvent processWebhook(String rawBody, Map<String, String> headers) throws PaymentException {
// Les webhooks PI-SPI passent par PispiWebhookResource qui valide l'IP et la signature en amont
throw new PaymentException(CODE, "Utiliser /api/pispi/webhook directement", 400);
}
@Override
public boolean isAvailable() {
return isConfigured();
}
private boolean isConfigured() {
return clientId != null && !clientId.isBlank()
&& institutionCode != null && !institutionCode.isBlank();
}
}

View File

@@ -0,0 +1,73 @@
package dev.lions.unionflow.server.payment.pispi;
import dev.lions.unionflow.server.api.payment.PaymentException;
import jakarta.enterprise.context.ApplicationScoped;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.security.MessageDigest;
import java.util.Arrays;
import java.util.HexFormat;
import java.util.Map;
import java.util.Optional;
@ApplicationScoped
public class PispiSignatureVerifier {
@ConfigProperty(name = "pispi.webhook.secret")
Optional<String> webhookSecretOpt;
@ConfigProperty(name = "pispi.webhook.allowed-ips")
Optional<String> allowedIpsOpt;
String webhookSecret;
String allowedIps;
@jakarta.annotation.PostConstruct
void init() {
webhookSecret = webhookSecretOpt.orElse("");
allowedIps = allowedIpsOpt.orElse("");
}
public boolean isIpAllowed(String ip) {
if (allowedIps == null || allowedIps.isBlank()) {
return true;
}
return Arrays.asList(allowedIps.split(",")).stream()
.map(String::trim)
.anyMatch(allowed -> allowed.equals(ip));
}
public boolean verifySignature(String rawBody, Map<String, String> headers) throws PaymentException {
if (webhookSecret == null || webhookSecret.isBlank()) {
return true;
}
// Recherche insensible à la casse
String receivedSignature = headers.entrySet().stream()
.filter(e -> "X-PISPI-Signature".equalsIgnoreCase(e.getKey()))
.map(Map.Entry::getValue)
.findFirst()
.orElse(null);
if (receivedSignature == null) {
throw new PaymentException("PISPI", "Signature PI-SPI absente", 401);
}
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(webhookSecret.getBytes(), "HmacSHA256"));
String computed = HexFormat.of().formatHex(mac.doFinal(rawBody.getBytes()));
if (!MessageDigest.isEqual(computed.getBytes(), receivedSignature.getBytes())) {
throw new PaymentException("PISPI", "Signature PI-SPI invalide", 401);
}
return true;
} catch (PaymentException e) {
throw e;
} catch (Exception e) {
throw new PaymentException("PISPI", "Erreur lors de la vérification de signature : " + e.getMessage(), 500, e);
}
}
}

View File

@@ -0,0 +1,71 @@
package dev.lions.unionflow.server.payment.pispi;
import dev.lions.unionflow.server.api.payment.PaymentEvent;
import dev.lions.unionflow.server.api.payment.PaymentException;
import dev.lions.unionflow.server.payment.orchestration.PaymentOrchestrator;
import dev.lions.unionflow.server.payment.pispi.dto.Pacs002Response;
import jakarta.annotation.security.PermitAll;
import jakarta.inject.Inject;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.HeaderParam;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.HttpHeaders;
import jakarta.ws.rs.core.Response;
import lombok.extern.slf4j.Slf4j;
import java.util.Map;
import java.util.stream.Collectors;
@Slf4j
@Path("/api/pispi/webhook")
public class PispiWebhookResource {
@Inject
PispiSignatureVerifier verifier;
@Inject
PispiIso20022Mapper mapper;
@Inject
PaymentOrchestrator orchestrator;
@POST
@Consumes("application/xml")
@PermitAll
public Response recevoir(
String rawXmlBody,
@Context HttpHeaders headers,
@HeaderParam("X-Forwarded-For") @DefaultValue("") String forwardedFor) {
String clientIp = forwardedFor.isBlank() ? "unknown" : forwardedFor.split(",")[0].trim();
if (!verifier.isIpAllowed(clientIp)) {
log.warn("PI-SPI webhook refusé — IP non autorisée : {}", clientIp);
return Response.status(403).entity("IP non autorisée").build();
}
Map<String, String> headersMap = headers.getRequestHeaders().entrySet().stream()
.collect(Collectors.toMap(Map.Entry::getKey, e -> e.getValue().get(0)));
try {
verifier.verifySignature(rawXmlBody, headersMap);
} catch (PaymentException e) {
log.warn("PI-SPI webhook — échec vérification signature : {}", e.getMessage());
return Response.status(401).entity(e.getMessage()).build();
}
try {
Pacs002Response pacs002 = Pacs002Response.fromXml(rawXmlBody);
PaymentEvent event = mapper.fromPacs002(pacs002);
orchestrator.handleEvent(event);
log.info("PI-SPI webhook traité : ref={}, statut={}", event.reference(), event.status());
return Response.ok().build();
} catch (Exception e) {
log.error("PI-SPI webhook — erreur traitement : {}", e.getMessage(), e);
return Response.serverError().entity("Erreur interne").build();
}
}
}

View File

@@ -0,0 +1,79 @@
package dev.lions.unionflow.server.payment.pispi.dto;
import org.w3c.dom.Document;
import org.w3c.dom.NodeList;
import org.xml.sax.InputSource;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import java.io.StringReader;
import java.time.Instant;
public class Pacs002Response {
private String originalMessageId;
private String originalEndToEndId;
private String transactionStatus;
private String rejectReasonCode;
private String clearingSystemReference;
private Instant acceptanceDateTime;
public Pacs002Response() {}
public String getOriginalMessageId() { return originalMessageId; }
public void setOriginalMessageId(String originalMessageId) { this.originalMessageId = originalMessageId; }
public String getOriginalEndToEndId() { return originalEndToEndId; }
public void setOriginalEndToEndId(String originalEndToEndId) { this.originalEndToEndId = originalEndToEndId; }
public String getTransactionStatus() { return transactionStatus; }
public void setTransactionStatus(String transactionStatus) { this.transactionStatus = transactionStatus; }
public String getRejectReasonCode() { return rejectReasonCode; }
public void setRejectReasonCode(String rejectReasonCode) { this.rejectReasonCode = rejectReasonCode; }
public String getClearingSystemReference() { return clearingSystemReference; }
public void setClearingSystemReference(String clearingSystemReference) { this.clearingSystemReference = clearingSystemReference; }
public Instant getAcceptanceDateTime() { return acceptanceDateTime; }
public void setAcceptanceDateTime(Instant acceptanceDateTime) { this.acceptanceDateTime = acceptanceDateTime; }
public static Pacs002Response fromXml(String xml) {
Pacs002Response response = new Pacs002Response();
try {
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
factory.setNamespaceAware(false);
// Désactiver les entités externes (OWASP XXE)
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
DocumentBuilder builder = factory.newDocumentBuilder();
Document doc = builder.parse(new InputSource(new StringReader(xml)));
doc.getDocumentElement().normalize();
response.setOriginalEndToEndId(firstText(doc, "OrgnlEndToEndId"));
response.setTransactionStatus(firstText(doc, "TxSts"));
response.setRejectReasonCode(firstText(doc, "RsnCd"));
response.setClearingSystemReference(firstText(doc, "ClrSysRef"));
String acptDtTm = firstText(doc, "AccptncDtTm");
if (acptDtTm != null && !acptDtTm.isBlank()) {
try {
response.setAcceptanceDateTime(Instant.parse(acptDtTm));
} catch (Exception ignored) {
// format non parsable — on laisse null
}
}
} catch (Exception e) {
throw new IllegalArgumentException("Impossible de parser le pacs.002 XML : " + e.getMessage(), e);
}
return response;
}
private static String firstText(Document doc, String tagName) {
NodeList nodes = doc.getElementsByTagName(tagName);
if (nodes.getLength() > 0) {
String text = nodes.item(0).getTextContent();
return (text == null || text.isBlank()) ? null : text.trim();
}
return null;
}
}

View File

@@ -0,0 +1,96 @@
package dev.lions.unionflow.server.payment.pispi.dto;
import java.math.BigDecimal;
public class Pacs008Request {
private String messageId;
private String creationDateTime;
private String numberOfTransactions;
private String endToEndId;
private String instrId;
private BigDecimal amount;
private String currency;
private String debtorName;
private String debtorBic;
private String creditorName;
private String creditorBic;
private String creditorIban;
private String remittanceInfo;
public Pacs008Request() {}
public String getMessageId() { return messageId; }
public void setMessageId(String messageId) { this.messageId = messageId; }
public String getCreationDateTime() { return creationDateTime; }
public void setCreationDateTime(String creationDateTime) { this.creationDateTime = creationDateTime; }
public String getNumberOfTransactions() { return numberOfTransactions; }
public void setNumberOfTransactions(String numberOfTransactions) { this.numberOfTransactions = numberOfTransactions; }
public String getEndToEndId() { return endToEndId; }
public void setEndToEndId(String endToEndId) { this.endToEndId = endToEndId; }
public String getInstrId() { return instrId; }
public void setInstrId(String instrId) { this.instrId = instrId; }
public BigDecimal getAmount() { return amount; }
public void setAmount(BigDecimal amount) { this.amount = amount; }
public String getCurrency() { return currency; }
public void setCurrency(String currency) { this.currency = currency; }
public String getDebtorName() { return debtorName; }
public void setDebtorName(String debtorName) { this.debtorName = debtorName; }
public String getDebtorBic() { return debtorBic; }
public void setDebtorBic(String debtorBic) { this.debtorBic = debtorBic; }
public String getCreditorName() { return creditorName; }
public void setCreditorName(String creditorName) { this.creditorName = creditorName; }
public String getCreditorBic() { return creditorBic; }
public void setCreditorBic(String creditorBic) { this.creditorBic = creditorBic; }
public String getCreditorIban() { return creditorIban; }
public void setCreditorIban(String creditorIban) { this.creditorIban = creditorIban; }
public String getRemittanceInfo() { return remittanceInfo; }
public void setRemittanceInfo(String remittanceInfo) { this.remittanceInfo = remittanceInfo; }
public String toXml() {
return "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" +
"<Document xmlns=\"urn:iso:std:iso:20022:tech:xsd:pacs.008.001.10\">\n" +
" <FIToFICstmrCdtTrf>\n" +
" <GrpHdr>\n" +
" <MsgId>" + escape(messageId) + "</MsgId>\n" +
" <CreDtTm>" + escape(creationDateTime) + "</CreDtTm>\n" +
" <NbOfTxs>1</NbOfTxs>\n" +
" </GrpHdr>\n" +
" <CdtTrfTxInf>\n" +
" <PmtId>\n" +
" <InstrId>" + escape(instrId) + "</InstrId>\n" +
" <EndToEndId>" + escape(endToEndId) + "</EndToEndId>\n" +
" </PmtId>\n" +
" <IntrBkSttlmAmt Ccy=\"" + escape(currency) + "\">" + (amount != null ? amount.toPlainString() : "0") + "</IntrBkSttlmAmt>\n" +
" <Dbtr><Nm>" + escape(debtorName) + "</Nm></Dbtr>\n" +
" <DbtrAgt><FinInstnId><BICFI>" + escape(debtorBic) + "</BICFI></FinInstnId></DbtrAgt>\n" +
" <Cdtr><Nm>" + escape(creditorName) + "</Nm></Cdtr>\n" +
" <CdtrAgt><FinInstnId><BICFI>" + escape(creditorBic) + "</BICFI></FinInstnId></CdtrAgt>\n" +
" <RmtInf><Ustrd>" + escape(remittanceInfo) + "</Ustrd></RmtInf>\n" +
" </CdtTrfTxInf>\n" +
" </FIToFICstmrCdtTrf>\n" +
"</Document>";
}
private static String escape(String value) {
if (value == null) return "";
return value
.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
.replace("'", "&apos;");
}
}

View File

@@ -0,0 +1,140 @@
package dev.lions.unionflow.server.payment.wave;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import dev.lions.unionflow.server.api.payment.*;
import dev.lions.unionflow.server.service.WaveCheckoutService;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.HexFormat;
import java.util.Map;
/**
* Implémentation Wave de PaymentProvider.
*
* <p>Délègue la création de session à {@link WaveCheckoutService} existant.
* Normalise les webhooks Wave vers {@link PaymentEvent}.
*/
@Slf4j
@ApplicationScoped
public class WavePaymentProvider implements PaymentProvider {
public static final String CODE = "WAVE";
@Inject
WaveCheckoutService waveCheckoutService;
@ConfigProperty(name = "wave.webhook.secret", defaultValue = "")
String webhookSecret;
private final ObjectMapper mapper = new ObjectMapper();
@Override
public String getProviderCode() {
return CODE;
}
@Override
public CheckoutSession initiateCheckout(CheckoutRequest request) throws PaymentException {
try {
String amount = request.amount().toBigInteger().toString();
WaveCheckoutService.WaveCheckoutSessionResponse resp = waveCheckoutService.createSession(
amount,
request.currency(),
request.successUrl(),
request.cancelUrl(),
request.reference(),
request.customerPhone()
);
return new CheckoutSession(
resp.id,
resp.waveLaunchUrl,
Instant.now().plusSeconds(3600),
Map.of("provider", CODE)
);
} catch (Exception e) {
throw new PaymentException(CODE, e.getMessage(), 500, e);
}
}
@Override
public PaymentStatus getStatus(String externalId) throws PaymentException {
// Wave ne fournit pas d'API de polling — le statut passe par les webhooks.
// Un polling naïf via la session URL n'est pas supporté.
log.warn("Wave ne supporte pas le polling de statut — utiliser les webhooks.");
return PaymentStatus.PROCESSING;
}
@Override
public PaymentEvent processWebhook(String rawBody, Map<String, String> headers) throws PaymentException {
verifierSignatureWave(rawBody, headers);
try {
JsonNode root = mapper.readTree(rawBody);
String type = root.path("type").asText();
JsonNode data = root.path("data");
String externalId = data.path("id").asText(null);
String clientRef = data.path("client_reference").asText(null);
String rawAmount = data.path("amount").asText("0");
BigDecimal amount = new BigDecimal(rawAmount);
PaymentStatus status = switch (type) {
case "checkout.session.completed" -> PaymentStatus.SUCCESS;
case "checkout.session.failed" -> PaymentStatus.FAILED;
case "checkout.session.expired" -> PaymentStatus.EXPIRED;
default -> PaymentStatus.PROCESSING;
};
return new PaymentEvent(
externalId,
clientRef,
status,
amount,
data.path("transaction_id").asText(null),
Instant.now()
);
} catch (Exception e) {
throw new PaymentException(CODE, "Webhook Wave malformé : " + e.getMessage(), 400, e);
}
}
private void verifierSignatureWave(String rawBody, Map<String, String> headers) throws PaymentException {
if (webhookSecret == null || webhookSecret.isBlank()) return;
String sigHeader = headers.get("wave-signature");
if (sigHeader == null) sigHeader = headers.get("Wave-Signature");
if (sigHeader == null) {
throw new PaymentException(CODE, "Signature webhook Wave absente", 401);
}
try {
String timestamp = "";
String receivedSig = "";
for (String part : sigHeader.split(",")) {
if (part.startsWith("t=")) timestamp = part.substring(2);
if (part.startsWith("v1=")) receivedSig = part.substring(3);
}
String payload = timestamp + "." + rawBody;
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(webhookSecret.getBytes(), "HmacSHA256"));
String computed = HexFormat.of().formatHex(mac.doFinal(payload.getBytes()));
if (!java.security.MessageDigest.isEqual(computed.getBytes(), receivedSig.getBytes())) {
throw new PaymentException(CODE, "Signature webhook Wave invalide", 401);
}
} catch (PaymentException e) {
throw e;
} catch (Exception e) {
throw new PaymentException(CODE, "Erreur vérification signature Wave : " + e.getMessage(), 500, e);
}
}
}

View File

@@ -0,0 +1,52 @@
package dev.lions.unionflow.server.repository;
import dev.lions.unionflow.server.api.enums.membre.NiveauRisqueKyc;
import dev.lions.unionflow.server.api.enums.membre.StatutKyc;
import dev.lions.unionflow.server.entity.KycDossier;
import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase;
import jakarta.enterprise.context.ApplicationScoped;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@ApplicationScoped
public class KycDossierRepository implements PanacheRepositoryBase<KycDossier, UUID> {
public Optional<KycDossier> findDossierActifByMembre(UUID membreId) {
return find("membre.id = ?1 AND actif = true", membreId).firstResultOptional();
}
public List<KycDossier> findByMembre(UUID membreId) {
return find("membre.id = ?1 ORDER BY dateCreation DESC", membreId).list();
}
public List<KycDossier> findByStatut(StatutKyc statut) {
return find("statut = ?1 AND actif = true", statut).list();
}
public List<KycDossier> findByNiveauRisque(NiveauRisqueKyc niveauRisque) {
return find("niveauRisque = ?1 AND actif = true ORDER BY scoreRisque DESC", niveauRisque).list();
}
public List<KycDossier> findPep() {
return find("estPep = true AND actif = true").list();
}
public List<KycDossier> findPiecesExpirantsAvant(LocalDate date) {
return find("dateExpirationPiece <= ?1 AND actif = true ORDER BY dateExpirationPiece ASC", date).list();
}
public long countByStatut(StatutKyc statut) {
return count("statut = ?1 AND actif = true", statut);
}
public long countPepActifs() {
return count("estPep = true AND actif = true");
}
public List<KycDossier> findByAnnee(int anneeReference) {
return find("anneeReference = ?1", anneeReference).list();
}
}

View File

@@ -0,0 +1,16 @@
package dev.lions.unionflow.server.repository.mutuelle;
import dev.lions.unionflow.server.entity.mutuelle.ParametresFinanciersMutuelle;
import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase;
import jakarta.enterprise.context.ApplicationScoped;
import java.util.Optional;
import java.util.UUID;
@ApplicationScoped
public class ParametresFinanciersMutuellRepository implements PanacheRepositoryBase<ParametresFinanciersMutuelle, UUID> {
public Optional<ParametresFinanciersMutuelle> findByOrganisation(UUID orgId) {
return find("organisation.id", orgId).firstResultOptional();
}
}

View File

@@ -0,0 +1,34 @@
package dev.lions.unionflow.server.repository.mutuelle.parts;
import dev.lions.unionflow.server.entity.mutuelle.parts.ComptePartsSociales;
import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase;
import jakarta.enterprise.context.ApplicationScoped;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@ApplicationScoped
public class ComptePartsSocialesRepository implements PanacheRepositoryBase<ComptePartsSociales, UUID> {
public Optional<ComptePartsSociales> findByNumeroCompte(String numeroCompte) {
return find("numeroCompte", numeroCompte).firstResultOptional();
}
public List<ComptePartsSociales> findByMembre(UUID membreId) {
return list("membre.id = ?1 AND actif = true", membreId);
}
public List<ComptePartsSociales> findByOrganisation(UUID orgId) {
return list("organisation.id = ?1 AND actif = true ORDER BY dateCreation DESC", orgId);
}
public Optional<ComptePartsSociales> findByMembreAndOrg(UUID membreId, UUID orgId) {
return find("membre.id = ?1 AND organisation.id = ?2 AND actif = true", membreId, orgId)
.firstResultOptional();
}
public long countByOrganisation(UUID orgId) {
return count("organisation.id = ?1 AND actif = true", orgId);
}
}

View File

@@ -0,0 +1,16 @@
package dev.lions.unionflow.server.repository.mutuelle.parts;
import dev.lions.unionflow.server.entity.mutuelle.parts.TransactionPartsSociales;
import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase;
import jakarta.enterprise.context.ApplicationScoped;
import java.util.List;
import java.util.UUID;
@ApplicationScoped
public class TransactionPartsSocialesRepository implements PanacheRepositoryBase<TransactionPartsSociales, UUID> {
public List<TransactionPartsSociales> findByCompte(UUID compteId) {
return list("compte.id = ?1 ORDER BY dateTransaction DESC", compteId);
}
}

View File

@@ -0,0 +1,64 @@
package dev.lions.unionflow.server.resource;
import dev.lions.unionflow.server.service.MigrerOrganisationsVersKeycloakService;
import dev.lions.unionflow.server.service.MigrerOrganisationsVersKeycloakService.MigrationReport;
import jakarta.annotation.security.RolesAllowed;
import jakarta.inject.Inject;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import lombok.extern.slf4j.Slf4j;
import java.util.Map;
/**
* Endpoints d'administration Keycloak 26 Organizations.
*
* <p>Réservés aux SUPER_ADMIN. Opérations à déclencher manuellement lors de la
* migration Keycloak 23 → 26.
*/
@Slf4j
@Path("/api/admin/keycloak")
@Produces(MediaType.APPLICATION_JSON)
@RolesAllowed("SUPER_ADMIN")
public class AdminKeycloakOrganisationResource {
@Inject
MigrerOrganisationsVersKeycloakService migrationService;
/**
* Lance la migration one-shot des organisations UnionFlow vers Keycloak 26 Organizations.
*
* <p>Idempotent : les organisations déjà migrées (keycloak_org_id non null) sont ignorées.
*
* @return rapport de migration (total, créés, ignorés, erreurs)
*/
@POST
@Path("/migrer-organisations")
public Response migrerOrganisations() {
log.info("Déclenchement migration organisations → Keycloak 26 Organizations");
try {
MigrationReport report = migrationService.migrerToutesLesOrganisations();
log.info("Migration terminée : {}", report);
return Response
.status(report.success() ? Response.Status.OK.getStatusCode() : 207)
.entity(Map.of(
"total", report.total(),
"crees", report.crees(),
"ignores", report.ignores(),
"erreurs", report.erreurs(),
"succes", report.success()
))
.build();
} catch (Exception e) {
log.error("Erreur critique lors de la migration : {}", e.getMessage(), e);
return Response.serverError()
.entity(Map.of("error", e.getMessage()))
.build();
}
}
}

View File

@@ -0,0 +1,98 @@
package dev.lions.unionflow.server.resource;
import dev.lions.unionflow.server.service.ComptabilitePdfService;
import jakarta.annotation.security.RolesAllowed;
import jakarta.inject.Inject;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.time.LocalDate;
import java.util.UUID;
import org.eclipse.microprofile.openapi.annotations.Operation;
import org.eclipse.microprofile.openapi.annotations.tags.Tag;
/**
* Endpoints de téléchargement des rapports comptables PDF SYSCOHADA révisé.
*/
@Path("/api/comptabilite/pdf")
@Produces(MediaType.APPLICATION_JSON)
@RolesAllowed({"ADMIN_ORGANISATION", "TRESORIER", "COMMISSAIRE_COMPTES", "SUPER_ADMIN"})
@Tag(name = "Comptabilité PDF", description = "Rapports comptables SYSCOHADA : balance, compte de résultat, grand livre")
public class ComptabilitePdfResource {
@Inject
ComptabilitePdfService comptabilitePdfService;
@GET
@Path("/organisations/{organisationId}/balance")
@Produces("application/pdf")
@Operation(summary = "Balance générale SYSCOHADA",
description = "Génère la balance générale (cumul débit/crédit/solde) pour la période.")
public Response balance(
@PathParam("organisationId") UUID organisationId,
@QueryParam("dateDebut") @DefaultValue("") String dateDebutStr,
@QueryParam("dateFin") @DefaultValue("") String dateFinStr) {
LocalDate dateDebut = parseDateOrStartOfYear(dateDebutStr);
LocalDate dateFin = parseDateOrToday(dateFinStr);
byte[] pdf = comptabilitePdfService.genererBalance(organisationId, dateDebut, dateFin);
return buildPdfResponse(pdf, "balance_" + organisationId + ".pdf");
}
@GET
@Path("/organisations/{organisationId}/compte-de-resultat")
@Produces("application/pdf")
@Operation(summary = "Compte de résultat SYSCOHADA",
description = "Génère le compte de résultat (produits classes 7/8 charges classes 6/8).")
public Response compteDeResultat(
@PathParam("organisationId") UUID organisationId,
@QueryParam("dateDebut") @DefaultValue("") String dateDebutStr,
@QueryParam("dateFin") @DefaultValue("") String dateFinStr) {
LocalDate dateDebut = parseDateOrStartOfYear(dateDebutStr);
LocalDate dateFin = parseDateOrToday(dateFinStr);
byte[] pdf = comptabilitePdfService.genererCompteResultat(organisationId, dateDebut, dateFin);
return buildPdfResponse(pdf, "compte_resultat_" + organisationId + ".pdf");
}
@GET
@Path("/organisations/{organisationId}/grand-livre/{numeroCompte}")
@Produces("application/pdf")
@Operation(summary = "Grand livre d'un compte SYSCOHADA",
description = "Génère le grand livre (détail chronologique) pour un compte comptable donné.")
public Response grandLivre(
@PathParam("organisationId") UUID organisationId,
@PathParam("numeroCompte") String numeroCompte,
@QueryParam("dateDebut") @DefaultValue("") String dateDebutStr,
@QueryParam("dateFin") @DefaultValue("") String dateFinStr) {
LocalDate dateDebut = parseDateOrStartOfYear(dateDebutStr);
LocalDate dateFin = parseDateOrToday(dateFinStr);
byte[] pdf = comptabilitePdfService.genererGrandLivre(organisationId, numeroCompte, dateDebut, dateFin);
return buildPdfResponse(pdf, "grand_livre_" + numeroCompte + ".pdf");
}
private static Response buildPdfResponse(byte[] pdf, String filename) {
return Response.ok(pdf)
.header("Content-Disposition", "attachment; filename=\"" + filename + "\"")
.header("Content-Length", pdf.length)
.build();
}
private static LocalDate parseDateOrStartOfYear(String s) {
if (s == null || s.isBlank()) return LocalDate.of(LocalDate.now().getYear(), 1, 1);
try { return LocalDate.parse(s); } catch (Exception e) {
throw new BadRequestException("Format de date invalide (attendu : YYYY-MM-DD) : " + s);
}
}
private static LocalDate parseDateOrToday(String s) {
if (s == null || s.isBlank()) return LocalDate.now();
try { return LocalDate.parse(s); } catch (Exception e) {
throw new BadRequestException("Format de date invalide (attendu : YYYY-MM-DD) : " + s);
}
}
}

View File

@@ -0,0 +1,111 @@
package dev.lions.unionflow.server.resource;
import dev.lions.unionflow.server.api.dto.kyc.KycDossierRequest;
import dev.lions.unionflow.server.api.dto.kyc.KycDossierResponse;
import dev.lions.unionflow.server.service.KycAmlService;
import io.quarkus.security.identity.SecurityIdentity;
import jakarta.annotation.security.RolesAllowed;
import jakarta.inject.Inject;
import jakarta.validation.Valid;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* Endpoints KYC/AML — gestion des dossiers d'identification et évaluation risque LCB-FT.
*/
@Path("/api/kyc")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class KycResource {
@Inject
KycAmlService kycAmlService;
@Inject
SecurityIdentity identity;
/** Soumet ou met à jour un dossier KYC pour un membre. */
@POST
@Path("/dossiers")
@RolesAllowed({"ADMIN_ORGANISATION", "TRESORIER", "SUPER_ADMIN"})
public Response soumettre(@Valid KycDossierRequest request) {
KycDossierResponse response = kycAmlService.soumettreOuMettreAJour(request, identity.getPrincipal().getName());
return Response.status(Response.Status.CREATED).entity(response).build();
}
/** Récupère le dossier KYC actif d'un membre. */
@GET
@Path("/membres/{membreId}")
@RolesAllowed({"ADMIN_ORGANISATION", "TRESORIER", "SUPER_ADMIN"})
public Response getDossierActif(@PathParam("membreId") UUID membreId) {
return kycAmlService.getDossierActif(membreId)
.map(d -> Response.ok(d).build())
.orElse(Response.status(Response.Status.NOT_FOUND)
.entity(Map.of("error", "Aucun dossier KYC actif pour ce membre."))
.build());
}
/** Évalue le score de risque LCB-FT du membre. */
@POST
@Path("/membres/{membreId}/evaluer-risque")
@RolesAllowed({"ADMIN_ORGANISATION", "TRESORIER", "SUPER_ADMIN"})
public Response evaluerRisque(@PathParam("membreId") UUID membreId) {
KycDossierResponse response = kycAmlService.evaluerRisque(membreId);
return Response.ok(response).build();
}
/** Valide manuellement un dossier KYC (agent habilité). */
@POST
@Path("/dossiers/{dossierId}/valider")
@RolesAllowed({"SUPER_ADMIN", "ADMIN_ORGANISATION"})
public Response valider(
@PathParam("dossierId") UUID dossierId,
@QueryParam("validateurId") UUID validateurId,
@QueryParam("notes") String notes) {
KycDossierResponse response = kycAmlService.valider(
dossierId, validateurId, notes, identity.getPrincipal().getName());
return Response.ok(response).build();
}
/** Refuse un dossier KYC avec motif. */
@POST
@Path("/dossiers/{dossierId}/refuser")
@RolesAllowed({"SUPER_ADMIN", "ADMIN_ORGANISATION"})
public Response refuser(
@PathParam("dossierId") UUID dossierId,
@QueryParam("validateurId") UUID validateurId,
@QueryParam("motif") String motif) {
KycDossierResponse response = kycAmlService.refuser(
dossierId, validateurId, motif, identity.getPrincipal().getName());
return Response.ok(response).build();
}
/** Liste les dossiers KYC en attente de validation. */
@GET
@Path("/dossiers/en-attente")
@RolesAllowed({"SUPER_ADMIN", "ADMIN_ORGANISATION"})
public List<KycDossierResponse> getDossiersEnAttente() {
return kycAmlService.getDossiersEnAttente();
}
/** Liste les membres PEP (Personnes Exposées Politiquement). */
@GET
@Path("/pep")
@RolesAllowed({"SUPER_ADMIN"})
public List<KycDossierResponse> getPep() {
return kycAmlService.getDossiersPep();
}
/** Pièces d'identité expirant dans les 30 jours. */
@GET
@Path("/pieces-expirant-bientot")
@RolesAllowed({"SUPER_ADMIN", "ADMIN_ORGANISATION", "TRESORIER"})
public List<KycDossierResponse> getPiecesExpirant() {
return kycAmlService.getPiecesExpirantDansLes30Jours();
}
}

View File

@@ -0,0 +1,139 @@
package dev.lions.unionflow.server.resource;
import dev.lions.unionflow.server.api.payment.*;
import dev.lions.unionflow.server.entity.SouscriptionOrganisation;
import dev.lions.unionflow.server.payment.orchestration.PaymentOrchestrator;
import dev.lions.unionflow.server.payment.orchestration.PaymentProviderRegistry;
import dev.lions.unionflow.server.repository.SouscriptionOrganisationRepository;
import jakarta.annotation.security.PermitAll;
import jakarta.annotation.security.RolesAllowed;
import jakarta.inject.Inject;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.HttpHeaders;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import lombok.extern.slf4j.Slf4j;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.stream.Collectors;
/**
* Endpoints de paiement unifiés — abstraction multi-provider.
* Remplace à terme les endpoints Wave-spécifiques.
*/
@Slf4j
@Path("/api/paiements")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class PaiementUnifieResource {
@Inject
PaymentOrchestrator orchestrator;
@Inject
PaymentProviderRegistry registry;
@Inject
SouscriptionOrganisationRepository souscriptionRepository;
/**
* Initie un paiement via le provider demandé (ou le provider par défaut).
*
* <p>Exemple : {@code POST /api/paiements/initier?provider=WAVE}
*/
@POST
@Path("/initier")
@RolesAllowed({"MEMBRE_ACTIF", "ADMIN_ORGANISATION", "TRESORIER", "SUPER_ADMIN"})
public Response initier(
@QueryParam("provider") String provider,
PaiementInitierRequest req) {
try {
// Si une souscription est fournie, utiliser le providerDefaut de sa formule
String resolvedProvider = provider;
if (req.souscriptionId() != null) {
resolvedProvider = souscriptionRepository.findByIdOptional(req.souscriptionId())
.map(SouscriptionOrganisation::getFormule)
.map(f -> f.getProviderDefaut())
.filter(p -> p != null && !p.isBlank())
.orElse(provider);
}
CheckoutRequest checkoutRequest = new CheckoutRequest(
req.montant(),
req.devise() != null ? req.devise() : "XOF",
req.telephone(),
req.email(),
req.reference(),
req.successUrl(),
req.cancelUrl(),
Map.of()
);
CheckoutSession session = orchestrator.initierPaiement(checkoutRequest, resolvedProvider);
return Response.ok(session).build();
} catch (PaymentException e) {
return Response.status(e.getHttpStatus())
.entity(Map.of("error", e.getMessage(), "provider", e.getProviderCode()))
.build();
}
}
/**
* Webhook entrant d'un provider. Vérifie la signature et met à jour le statut.
* Route : {@code POST /api/paiements/webhook/{provider}}
*/
@POST
@Path("/webhook/{provider}")
@PermitAll
@Consumes(MediaType.WILDCARD)
public Response webhook(
@PathParam("provider") String providerCode,
String rawBody,
@Context HttpHeaders httpHeaders) {
try {
PaymentProvider provider = registry.get(providerCode.toUpperCase());
Map<String, String> headers = httpHeaders.getRequestHeaders().entrySet().stream()
.collect(Collectors.toMap(
Map.Entry::getKey,
e -> e.getValue().isEmpty() ? "" : e.getValue().get(0)
));
PaymentEvent event = provider.processWebhook(rawBody, headers);
orchestrator.handleEvent(event);
return Response.ok().build();
} catch (UnsupportedOperationException e) {
return Response.status(Response.Status.NOT_FOUND)
.entity(Map.of("error", "Provider inconnu : " + providerCode))
.build();
} catch (PaymentException e) {
log.error("Webhook {} rejeté : {}", providerCode, e.getMessage());
return Response.status(e.getHttpStatus())
.entity(Map.of("error", e.getMessage()))
.build();
}
}
/** Retourne les providers de paiement disponibles. */
@GET
@Path("/providers")
@RolesAllowed({"ADMIN_ORGANISATION", "SUPER_ADMIN"})
public List<String> getProviders() {
return registry.getAvailableCodes();
}
public record PaiementInitierRequest(
BigDecimal montant,
String devise,
String telephone,
String email,
String reference,
String successUrl,
String cancelUrl,
/** Optionnel — si fourni, le providerDefaut de la formule prend le dessus sur le query param. */
UUID souscriptionId
) {}
}

View File

@@ -0,0 +1,52 @@
package dev.lions.unionflow.server.resource.mutuelle;
import dev.lions.unionflow.server.api.dto.mutuelle.financier.ParametresFinanciersMutuellRequest;
import dev.lions.unionflow.server.api.dto.mutuelle.financier.ParametresFinanciersMutuellResponse;
import dev.lions.unionflow.server.security.RequiresModule;
import dev.lions.unionflow.server.service.mutuelle.InteretsEpargneService;
import dev.lions.unionflow.server.service.mutuelle.ParametresFinanciersService;
import jakarta.annotation.security.RolesAllowed;
import jakarta.inject.Inject;
import jakarta.validation.Valid;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.util.Map;
import java.util.UUID;
@Path("/api/v1/mutuelle/parametres-financiers")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@RequiresModule("EPARGNE")
public class ParametresFinanciersResource {
@Inject ParametresFinanciersService parametresService;
@Inject InteretsEpargneService interetsService;
@GET
@Path("/{orgId}")
@RolesAllowed({"ADMIN", "SUPER_ADMIN", "ADMIN_ORGANISATION", "MUTUELLE_RESP"})
public Response getByOrganisation(@PathParam("orgId") UUID orgId) {
return Response.ok(parametresService.getByOrganisation(orgId)).build();
}
@POST
@RolesAllowed({"ADMIN", "SUPER_ADMIN", "ADMIN_ORGANISATION"})
public Response creerOuMettrAJour(@Valid ParametresFinanciersMutuellRequest request) {
ParametresFinanciersMutuellResponse resp = parametresService.creerOuMettrAJour(request);
return Response.ok(resp).build();
}
/**
* Déclenche manuellement le calcul des intérêts / dividendes pour une organisation.
* Utile pour régularisation ou test.
*/
@POST
@Path("/{orgId}/calculer-interets")
@RolesAllowed({"ADMIN", "SUPER_ADMIN", "ADMIN_ORGANISATION"})
public Response calculerInterets(@PathParam("orgId") UUID orgId) {
Map<String, Object> result = interetsService.calculerManuellement(orgId);
return Response.ok(result).build();
}
}

View File

@@ -0,0 +1,74 @@
package dev.lions.unionflow.server.resource.mutuelle;
import dev.lions.unionflow.server.security.RequiresModule;
import dev.lions.unionflow.server.service.mutuelle.ReleveComptePdfService;
import jakarta.annotation.security.RolesAllowed;
import jakarta.inject.Inject;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.Response.ResponseBuilder;
import java.time.LocalDate;
import java.util.UUID;
/**
* Relevés de compte en PDF.
* - GET /api/v1/releves/epargne/{compteId} → relevé épargne
* - GET /api/v1/releves/parts-sociales/{compteId} → relevé parts sociales
*/
@Path("/api/v1/releves")
@RequiresModule("EPARGNE")
public class ReleveCompteResource {
@Inject ReleveComptePdfService releveService;
@GET
@Path("/epargne/{compteId}")
@Produces("application/pdf")
@RolesAllowed({"ADMIN", "SUPER_ADMIN", "ADMIN_ORGANISATION", "MUTUELLE_RESP", "MEMBRE", "USER"})
public Response releveEpargne(
@PathParam("compteId") UUID compteId,
@QueryParam("dateDebut") String dateDebutStr,
@QueryParam("dateFin") String dateFinStr) {
LocalDate dateDebut = parseDate(dateDebutStr);
LocalDate dateFin = parseDate(dateFinStr);
byte[] pdf = releveService.genererReleveEpargne(compteId, dateDebut, dateFin);
ResponseBuilder rb = Response.ok(pdf);
rb.header("Content-Disposition",
"attachment; filename=\"releve-epargne-" + compteId + ".pdf\"");
rb.header("Content-Type", MediaType.valueOf("application/pdf"));
return rb.build();
}
@GET
@Path("/parts-sociales/{compteId}")
@Produces("application/pdf")
@RolesAllowed({"ADMIN", "SUPER_ADMIN", "ADMIN_ORGANISATION", "MUTUELLE_RESP", "MEMBRE", "USER"})
public Response releveParts(
@PathParam("compteId") UUID compteId,
@QueryParam("dateDebut") String dateDebutStr,
@QueryParam("dateFin") String dateFinStr) {
LocalDate dateDebut = parseDate(dateDebutStr);
LocalDate dateFin = parseDate(dateFinStr);
byte[] pdf = releveService.genererReleveParts(compteId, dateDebut, dateFin);
ResponseBuilder rb = Response.ok(pdf);
rb.header("Content-Disposition",
"attachment; filename=\"releve-parts-" + compteId + ".pdf\"");
rb.header("Content-Type", MediaType.valueOf("application/pdf"));
return rb.build();
}
private LocalDate parseDate(String s) {
if (s == null || s.isBlank()) return null;
try {
return LocalDate.parse(s);
} catch (Exception e) {
throw new IllegalArgumentException("Format de date invalide. Utilisez YYYY-MM-DD. Valeur reçue: " + s);
}
}
}

View File

@@ -0,0 +1,73 @@
package dev.lions.unionflow.server.resource.mutuelle.parts;
import dev.lions.unionflow.server.api.dto.mutuelle.parts.ComptePartsSocialesRequest;
import dev.lions.unionflow.server.api.dto.mutuelle.parts.ComptePartsSocialesResponse;
import dev.lions.unionflow.server.api.dto.mutuelle.parts.TransactionPartsSocialesRequest;
import dev.lions.unionflow.server.api.dto.mutuelle.parts.TransactionPartsSocialesResponse;
import dev.lions.unionflow.server.security.RequiresModule;
import dev.lions.unionflow.server.service.mutuelle.parts.ComptePartsSocialesService;
import jakarta.annotation.security.RolesAllowed;
import jakarta.inject.Inject;
import jakarta.validation.Valid;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.util.List;
import java.util.UUID;
@Path("/api/v1/parts-sociales/comptes")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@RequiresModule("EPARGNE")
public class ComptePartsSocialesResource {
@Inject
ComptePartsSocialesService service;
@POST
@RolesAllowed({"ADMIN", "SUPER_ADMIN", "ADMIN_ORGANISATION", "MUTUELLE_RESP"})
public Response ouvrirCompte(@Valid ComptePartsSocialesRequest request) {
ComptePartsSocialesResponse resp = service.ouvrirCompte(request);
return Response.status(Response.Status.CREATED).entity(resp).build();
}
@POST
@Path("/transactions")
@RolesAllowed({"ADMIN", "SUPER_ADMIN", "ADMIN_ORGANISATION", "MUTUELLE_RESP"})
public Response enregistrerTransaction(@Valid TransactionPartsSocialesRequest request) {
TransactionPartsSocialesResponse resp = service.enregistrerSouscription(request);
return Response.status(Response.Status.CREATED).entity(resp).build();
}
@GET
@Path("/{id}")
@RolesAllowed({"ADMIN", "SUPER_ADMIN", "ADMIN_ORGANISATION", "MUTUELLE_RESP", "MEMBRE", "USER"})
public Response getById(@PathParam("id") UUID id) {
return Response.ok(service.getById(id)).build();
}
@GET
@Path("/membre/{membreId}")
@RolesAllowed({"ADMIN", "SUPER_ADMIN", "ADMIN_ORGANISATION", "MUTUELLE_RESP", "MEMBRE", "USER"})
public Response getByMembre(@PathParam("membreId") UUID membreId) {
List<ComptePartsSocialesResponse> list = service.getByMembre(membreId);
return Response.ok(list).build();
}
@GET
@Path("/organisation/{orgId}")
@RolesAllowed({"ADMIN", "SUPER_ADMIN", "ADMIN_ORGANISATION", "MUTUELLE_RESP"})
public Response getByOrganisation(@PathParam("orgId") UUID orgId) {
List<ComptePartsSocialesResponse> list = service.getByOrganisation(orgId);
return Response.ok(list).build();
}
@GET
@Path("/{id}/transactions")
@RolesAllowed({"ADMIN", "SUPER_ADMIN", "ADMIN_ORGANISATION", "MUTUELLE_RESP", "MEMBRE", "USER"})
public Response getTransactions(@PathParam("id") UUID id) {
List<TransactionPartsSocialesResponse> list = service.getTransactions(id);
return Response.ok(list).build();
}
}

View File

@@ -0,0 +1,116 @@
package dev.lions.unionflow.server.security;
import dev.lions.unionflow.server.entity.Organisation;
import dev.lions.unionflow.server.repository.OrganisationRepository;
import io.smallrye.jwt.auth.principal.JWTParser;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.ws.rs.BadRequestException;
import jakarta.ws.rs.ForbiddenException;
import org.eclipse.microprofile.jwt.JsonWebToken;
import org.jboss.logging.Logger;
import java.util.Optional;
import java.util.UUID;
/**
* Résout l'organisation active depuis le claim {@code organization} du JWT Keycloak 26.
*
* <p>Keycloak 26 Organizations injecte dans le token un claim de la forme :
* <pre>
* "organization": {
* "mutuelle-gbane": { "id": "uuid-kc-org", "name": "Mutuelle GBANE", "alias": "mutuelle-gbane" }
* }
* </pre>
*
* <p>Ce bean remplace progressivement {@link OrganisationContextFilter} (header-based).
* Pendant la période de transition, le filtre header reste actif — ce resolver est
* utilisé en complément par les endpoints qui lisent explicitement le claim JWT.
*
* <p>Un token scopé à une seule organization → résolution directe.
* Un token multi-org sans scoping → exception (le client doit re-authentifier avec scoping).
*/
@ApplicationScoped
public class OrganisationContextResolver {
private static final Logger LOG = Logger.getLogger(OrganisationContextResolver.class);
@Inject
JsonWebToken jwt;
@Inject
OrganisationRepository organisationRepository;
/**
* Résout l'UUID UnionFlow de l'organisation active depuis le claim JWT {@code organization}.
*
* @throws BadRequestException si le token est multi-org sans scoping ou si le claim manque
* @throws ForbiddenException si aucune organisation UnionFlow ne correspond au keycloak_org_id
*/
public UUID resolveOrganisationId() {
var orgClaim = jwt.<java.util.Map<String, Object>>getClaim("organization");
if (orgClaim == null || orgClaim.isEmpty()) {
throw new BadRequestException(
"Token JWT sans claim 'organization' — connectez-vous dans le contexte d'une organisation.");
}
if (orgClaim.size() > 1) {
throw new BadRequestException(
"Token multi-organisation non scopé. Ré-authentifiez-vous avec l'organisation cible.");
}
// Single-org token : prendre la première (et seule) entrée
var entry = orgClaim.entrySet().iterator().next().getValue();
String kcOrgIdStr = extractId(entry);
if (kcOrgIdStr == null) {
LOG.warnf("Claim organization sans champ 'id' : %s", entry);
throw new BadRequestException("Claim 'organization' malformé — champ 'id' manquant.");
}
UUID kcOrgId;
try {
kcOrgId = UUID.fromString(kcOrgIdStr);
} catch (IllegalArgumentException e) {
throw new BadRequestException("Claim organization.id n'est pas un UUID valide : " + kcOrgIdStr);
}
Optional<Organisation> orgOpt = organisationRepository
.find("keycloakOrgId = ?1 AND actif = true", kcOrgId)
.firstResultOptional();
if (orgOpt.isEmpty()) {
LOG.warnf("Aucune organisation UnionFlow avec keycloak_org_id=%s", kcOrgId);
throw new ForbiddenException(
"Aucune organisation active trouvée pour cet identifiant Keycloak Organization.");
}
return orgOpt.get().getId();
}
/**
* Variante qui retourne un {@code Optional} vide si le claim est absent
* (pour les endpoints compatibles avec les deux modes header + JWT).
*/
public Optional<UUID> resolveOrganisationIdIfPresent() {
try {
var orgClaim = jwt.<java.util.Map<String, Object>>getClaim("organization");
if (orgClaim == null || orgClaim.isEmpty()) {
return Optional.empty();
}
return Optional.of(resolveOrganisationId());
} catch (BadRequestException | ForbiddenException e) {
return Optional.empty();
}
}
@SuppressWarnings("unchecked")
private String extractId(Object entry) {
if (entry instanceof java.util.Map) {
Object id = ((java.util.Map<String, Object>) entry).get("id");
return id != null ? id.toString() : null;
}
return null;
}
}

View File

@@ -0,0 +1,77 @@
package dev.lions.unionflow.server.security;
import io.quarkus.security.identity.SecurityIdentity;
import jakarta.annotation.Priority;
import jakarta.inject.Inject;
import jakarta.ws.rs.Priorities;
import jakarta.ws.rs.container.ContainerRequestContext;
import jakarta.ws.rs.container.ContainerRequestFilter;
import jakarta.ws.rs.ext.Provider;
import lombok.extern.slf4j.Slf4j;
import javax.sql.DataSource;
import java.io.IOException;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.util.UUID;
/**
* Filtre JAX-RS qui positionne les variables de session PostgreSQL pour le RLS.
*
* <p>Doit s'exécuter APRÈS {@link OrganisationContextFilter} (priorité AUTHORIZATION + 20).
*
* <p>Variables positionnées :
* <ul>
* <li>{@code app.current_org_id} : UUID de l'organisation active (null → "00000000-0000-0000-0000-000000000000")</li>
* <li>{@code app.is_super_admin} : 'true' si SUPER_ADMIN (bypass RLS pour requêtes cross-tenant)</li>
* </ul>
*
* <p><strong>Limitation connue</strong> : ce filtre ouvre une connexion séparée du pool Agroal.
* {@code SET LOCAL} affecte CETTE connexion, pas celle utilisée par Hibernate pour les queries.
* Pour une isolation réelle, il faut brancher le {@code SET} sur le même contexte transactionnel
* Hibernate — via {@code CurrentTenantIdentifierResolver} + {@code MultiTenantConnectionProvider},
* ou via un {@code TransactionSynchronization} qui s'exécute dans la même transaction JTA.
* Ce filtre est un draft de préparation prod ; l'intégration complète est prévue en P2.4.
*
* <p>En dev, RLS est désactivé de fait car le user {@code skyfile} est owner
* et bypasse naturellement les policies. Ce filter est actif pour la préparation prod.
*/
@Slf4j
@Provider
@Priority(Priorities.AUTHORIZATION + 20)
public class RlsConnectionInitializer implements ContainerRequestFilter {
private static final String NULL_ORG_ID = "00000000-0000-0000-0000-000000000000";
@Inject
OrganisationContextHolder contextHolder;
@Inject
SecurityIdentity identity;
@Inject
DataSource dataSource;
@Override
public void filter(ContainerRequestContext requestContext) throws IOException {
if (identity == null || identity.isAnonymous()) return;
boolean isSuperAdmin = identity.getRoles() != null
&& (identity.getRoles().contains("SUPER_ADMIN")
|| identity.getRoles().contains("SUPERADMIN"));
UUID orgId = contextHolder.hasContext() ? contextHolder.getOrganisationId() : null;
String orgIdStr = orgId != null ? orgId.toString() : NULL_ORG_ID;
try (Connection conn = dataSource.getConnection()) {
try (PreparedStatement stmt = conn.prepareStatement(
"SET LOCAL app.current_org_id = '" + orgIdStr + "'; "
+ "SET LOCAL app.is_super_admin = '" + isSuperAdmin + "'")) {
stmt.execute();
}
} catch (Exception e) {
// Non bloquant en dev (user owner bypasse RLS)
log.debug("RLS session variables non positionnées (ignoré en dev) : {}", e.getMessage());
}
}
}

View File

@@ -0,0 +1,73 @@
package dev.lions.unionflow.server.security;
import io.quarkus.security.identity.SecurityIdentity;
import jakarta.annotation.Priority;
import jakarta.inject.Inject;
import jakarta.interceptor.AroundInvoke;
import jakarta.interceptor.Interceptor;
import jakarta.interceptor.InvocationContext;
import jakarta.persistence.EntityManager;
import lombok.extern.slf4j.Slf4j;
import java.util.UUID;
/**
* Intercepteur CDI qui positionne les variables de session PostgreSQL pour le RLS
* DANS la même connexion JTA que Hibernate.
*
* <p>Priorité 300 : s'exécute APRÈS l'intercepteur {@code @Transactional} (priorité ~200)
* mais AVANT le code métier, garantissant que {@code SET LOCAL} affecte la connexion
* JTA active.
*
* <p>Utilise {@code set_config(name, value, true)} (is_local=true) qui est l'équivalent
* de {@code SET LOCAL} et s'annule automatiquement en fin de transaction.
*
* <p>Si aucun contexte d'organisation n'est disponible (SUPER_ADMIN sans org, ou endpoint
* public), positionne l'UUID nul pour que les policies RLS utilisent le fallback.
*/
@Slf4j
@Interceptor
@RlsEnabled
@Priority(300)
public class RlsContextInterceptor {
private static final String NULL_ORG_UUID = "00000000-0000-0000-0000-000000000000";
@Inject
EntityManager em;
@Inject
OrganisationContextHolder contextHolder;
@Inject
SecurityIdentity identity;
@AroundInvoke
Object applyRlsContext(InvocationContext ctx) throws Exception {
if (identity == null || identity.isAnonymous()) {
return ctx.proceed();
}
boolean isSuperAdmin = identity.getRoles() != null
&& (identity.getRoles().contains("SUPER_ADMIN")
|| identity.getRoles().contains("SUPERADMIN"));
UUID orgId = contextHolder.hasContext() ? contextHolder.getOrganisationId() : null;
String orgIdStr = orgId != null ? orgId.toString() : NULL_ORG_UUID;
try {
em.createNativeQuery(
"SELECT set_config('app.current_org_id', :orgId, true), "
+ "set_config('app.is_super_admin', :isSuperAdmin, true)")
.setParameter("orgId", orgIdStr)
.setParameter("isSuperAdmin", String.valueOf(isSuperAdmin))
.getSingleResult();
log.debug("RLS context positionné : org={}, superAdmin={}", orgIdStr, isSuperAdmin);
} catch (Exception e) {
// Non bloquant : en dev, le user owner bypasse naturellement les policies
log.debug("RLS set_config ignoré (probablement hors transaction) : {}", e.getMessage());
}
return ctx.proceed();
}
}

View File

@@ -0,0 +1,26 @@
package dev.lions.unionflow.server.security;
import jakarta.interceptor.InterceptorBinding;
import java.lang.annotation.*;
/**
* Marque une méthode ou classe transactionnelle pour que le filtre RLS
* positionne les variables de session PostgreSQL ({@code app.current_org_id},
* {@code app.is_super_admin}) dans la même connexion JTA que Hibernate.
*
* <p>Doit toujours être combiné avec {@code @Transactional} (ou être dans une
* méthode appelée depuis un contexte transactionnel existant).
*
* <p>Usage :
* <pre>{@code
* @RlsEnabled
* @Transactional
* public List<Cotisation> findAll() { ... }
* }</pre>
*/
@Inherited
@InterceptorBinding
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface RlsEnabled {
}

View File

@@ -0,0 +1,435 @@
package dev.lions.unionflow.server.service;
import com.lowagie.text.*;
import com.lowagie.text.Font;
import com.lowagie.text.pdf.*;
import dev.lions.unionflow.server.api.enums.comptabilite.TypeCompteComptable;
import dev.lions.unionflow.server.entity.CompteComptable;
import dev.lions.unionflow.server.entity.EcritureComptable;
import dev.lions.unionflow.server.entity.LigneEcriture;
import dev.lions.unionflow.server.entity.Organisation;
import dev.lions.unionflow.server.repository.CompteComptableRepository;
import dev.lions.unionflow.server.repository.EcritureComptableRepository;
import dev.lions.unionflow.server.repository.OrganisationRepository;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.ws.rs.NotFoundException;
import lombok.extern.slf4j.Slf4j;
import java.awt.Color;
import java.io.ByteArrayOutputStream;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.stream.Collectors;
/**
* Génération des rapports comptables PDF SYSCOHADA révisé.
*
* <p>Rapports disponibles :
* <ul>
* <li>Grand livre : détail de toutes les écritures par compte</li>
* <li>Balance générale : soldes débit/crédit/solde net par compte</li>
* <li>Compte de résultat : produits (classe 7+8) - charges (classe 6+8)</li>
* </ul>
*/
@Slf4j
@ApplicationScoped
public class ComptabilitePdfService {
private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("dd/MM/yyyy");
private static final Color COLOR_HEADER = new Color(0x1A, 0x56, 0x8C);
private static final Color COLOR_HEADER_TEXT = Color.WHITE;
private static final Color COLOR_TOTAL_ROW = new Color(0xE8, 0xF0, 0xFE);
private static final Color COLOR_ROW_ALT = new Color(0xF8, 0xFA, 0xFF);
@Inject
OrganisationRepository organisationRepository;
@Inject
CompteComptableRepository compteComptableRepository;
@Inject
EcritureComptableRepository ecritureComptableRepository;
// ── Balance générale ─────────────────────────────────────────────────────
/**
* Génère la balance générale SYSCOHADA pour une organisation.
* Liste tous les comptes avec cumul débit, cumul crédit et solde.
*/
public byte[] genererBalance(UUID organisationId, LocalDate dateDebut, LocalDate dateFin) {
Organisation org = getOrg(organisationId);
List<CompteComptable> comptes = compteComptableRepository.findByOrganisation(organisationId);
Map<String, BigDecimal[]> totauxParCompte = calculerTotauxParCompte(organisationId, dateDebut, dateFin);
try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
Document doc = new Document(PageSize.A4.rotate(), 20, 20, 40, 40);
PdfWriter.getInstance(doc, baos);
doc.open();
addTitrePage(doc, "BALANCE GÉNÉRALE", org.getNom(), dateDebut, dateFin);
PdfPTable table = new PdfPTable(6);
table.setWidthPercentage(100);
table.setWidths(new float[]{10f, 30f, 8f, 15f, 15f, 15f});
addHeaderCell(table, "Compte");
addHeaderCell(table, "Libellé");
addHeaderCell(table, "Classe");
addHeaderCell(table, "Cumul Débit");
addHeaderCell(table, "Cumul Crédit");
addHeaderCell(table, "Solde");
BigDecimal totalDebit = BigDecimal.ZERO;
BigDecimal totalCredit = BigDecimal.ZERO;
boolean alt = false;
for (CompteComptable compte : comptes) {
BigDecimal[] totaux = totauxParCompte.getOrDefault(
compte.getNumeroCompte(), new BigDecimal[]{BigDecimal.ZERO, BigDecimal.ZERO});
BigDecimal debit = totaux[0];
BigDecimal credit = totaux[1];
BigDecimal solde = debit.subtract(credit);
if (debit.signum() == 0 && credit.signum() == 0) continue;
Color bg = alt ? COLOR_ROW_ALT : Color.WHITE;
addDataCell(table, compte.getNumeroCompte(), bg);
addDataCell(table, compte.getLibelle(), bg);
addDataCell(table, String.valueOf(compte.getClasseComptable()), bg);
addAmountCell(table, debit, bg);
addAmountCell(table, credit, bg);
addAmountCell(table, solde, bg);
totalDebit = totalDebit.add(debit);
totalCredit = totalCredit.add(credit);
alt = !alt;
}
// Ligne totaux
BigDecimal totalSolde = totalDebit.subtract(totalCredit);
addTotalCell(table, "TOTAUX");
addTotalCell(table, "");
addTotalCell(table, "");
addAmountCell(table, totalDebit, COLOR_TOTAL_ROW);
addAmountCell(table, totalCredit, COLOR_TOTAL_ROW);
addAmountCell(table, totalSolde, COLOR_TOTAL_ROW);
doc.add(table);
addFooter(doc);
doc.close();
return baos.toByteArray();
} catch (Exception e) {
log.error("Erreur génération balance PDF : {}", e.getMessage(), e);
throw new RuntimeException("Erreur génération balance PDF", e);
}
}
// ── Compte de résultat ────────────────────────────────────────────────────
/**
* Génère le compte de résultat SYSCOHADA.
* Produits (classes 7 et 8 produits) — Charges (classes 6 et 8 charges).
*/
public byte[] genererCompteResultat(UUID organisationId, LocalDate dateDebut, LocalDate dateFin) {
Organisation org = getOrg(organisationId);
Map<String, BigDecimal[]> totaux = calculerTotauxParCompte(organisationId, dateDebut, dateFin);
List<CompteComptable> comptes = compteComptableRepository.findByOrganisation(organisationId);
BigDecimal totalProduits = BigDecimal.ZERO;
BigDecimal totalCharges = BigDecimal.ZERO;
List<Object[]> lignesProduits = new ArrayList<>();
List<Object[]> lignesCharges = new ArrayList<>();
for (CompteComptable compte : comptes) {
int classe = compte.getClasseComptable();
BigDecimal[] t = totaux.getOrDefault(compte.getNumeroCompte(),
new BigDecimal[]{BigDecimal.ZERO, BigDecimal.ZERO});
BigDecimal solde = t[1].subtract(t[0]); // crédit - débit pour produits
if ((classe == 7) || (classe == 8 && TypeCompteComptable.PRODUITS.equals(compte.getTypeCompte()))) {
if (solde.signum() != 0) {
lignesProduits.add(new Object[]{compte.getNumeroCompte(), compte.getLibelle(), solde});
totalProduits = totalProduits.add(solde);
}
} else if ((classe == 6) || (classe == 8 && TypeCompteComptable.CHARGES.equals(compte.getTypeCompte()))) {
BigDecimal soldeCharge = t[0].subtract(t[1]); // débit - crédit pour charges
if (soldeCharge.signum() != 0) {
lignesCharges.add(new Object[]{compte.getNumeroCompte(), compte.getLibelle(), soldeCharge});
totalCharges = totalCharges.add(soldeCharge);
}
}
}
BigDecimal resultat = totalProduits.subtract(totalCharges);
boolean benefice = resultat.signum() >= 0;
try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
Document doc = new Document(PageSize.A4, 30, 30, 50, 40);
PdfWriter.getInstance(doc, baos);
doc.open();
addTitrePage(doc, "COMPTE DE RÉSULTAT", org.getNom(), dateDebut, dateFin);
// Section PRODUITS
addSectionTitle(doc, "PRODUITS D'EXPLOITATION");
PdfPTable tableProduits = creerTableau2Colonnes();
for (Object[] ligne : lignesProduits) {
addDataCell(tableProduits, ligne[0] + "" + ligne[1], Color.WHITE);
addAmountCell(tableProduits, (BigDecimal) ligne[2], Color.WHITE);
}
addTotalCell(tableProduits, "TOTAL PRODUITS");
addAmountCell(tableProduits, totalProduits, COLOR_TOTAL_ROW);
doc.add(tableProduits);
doc.add(new Paragraph(" "));
// Section CHARGES
addSectionTitle(doc, "CHARGES D'EXPLOITATION");
PdfPTable tableCharges = creerTableau2Colonnes();
for (Object[] ligne : lignesCharges) {
addDataCell(tableCharges, ligne[0] + "" + ligne[1], Color.WHITE);
addAmountCell(tableCharges, (BigDecimal) ligne[2], Color.WHITE);
}
addTotalCell(tableCharges, "TOTAL CHARGES");
addAmountCell(tableCharges, totalCharges, COLOR_TOTAL_ROW);
doc.add(tableCharges);
doc.add(new Paragraph(" "));
// Résultat net
PdfPTable tableResultat = creerTableau2Colonnes();
String libelleResultat = benefice ? "BÉNÉFICE NET DE L'EXERCICE" : "PERTE NETTE DE L'EXERCICE";
Color couleurResultat = benefice ? new Color(0x00, 0x80, 0x00) : new Color(0xCC, 0x00, 0x00);
PdfPCell cellResultat = new PdfPCell(
new Phrase(libelleResultat, FontFactory.getFont(FontFactory.HELVETICA_BOLD, 11, couleurResultat)));
cellResultat.setBackgroundColor(new Color(0xF0, 0xF8, 0xE8));
cellResultat.setPadding(8);
tableResultat.addCell(cellResultat);
addAmountCell(tableResultat, resultat.abs(), new Color(0xF0, 0xF8, 0xE8));
doc.add(tableResultat);
addFooter(doc);
doc.close();
return baos.toByteArray();
} catch (Exception e) {
log.error("Erreur génération compte de résultat PDF : {}", e.getMessage(), e);
throw new RuntimeException("Erreur génération compte de résultat PDF", e);
}
}
// ── Grand livre ───────────────────────────────────────────────────────────
/**
* Génère le grand livre pour un compte donné.
*/
public byte[] genererGrandLivre(UUID organisationId, String numeroCompte,
LocalDate dateDebut, LocalDate dateFin) {
Organisation org = getOrg(organisationId);
CompteComptable compte = compteComptableRepository
.findByOrganisationAndNumero(organisationId, numeroCompte)
.orElseThrow(() -> new NotFoundException(
"Compte " + numeroCompte + " introuvable pour l'org " + organisationId));
List<EcritureComptable> ecritures = ecritureComptableRepository
.findByOrganisationAndDateRange(organisationId, dateDebut, dateFin);
// Filtrer les lignes qui concernent ce compte
List<Object[]> mouvements = new ArrayList<>();
BigDecimal solde = BigDecimal.ZERO;
for (EcritureComptable ecriture : ecritures) {
if (ecriture.getLignes() == null) continue;
for (LigneEcriture ligne : ecriture.getLignes()) {
if (ligne.getCompteComptable() == null) continue;
if (!numeroCompte.equals(ligne.getCompteComptable().getNumeroCompte())) continue;
BigDecimal debit = ligne.getMontantDebit() != null ? ligne.getMontantDebit() : BigDecimal.ZERO;
BigDecimal credit = ligne.getMontantCredit() != null ? ligne.getMontantCredit() : BigDecimal.ZERO;
solde = solde.add(debit).subtract(credit);
mouvements.add(new Object[]{
ecriture.getDateEcriture(),
ecriture.getNumeroPiece(),
ecriture.getLibelle(),
debit,
credit,
solde
});
}
}
try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
Document doc = new Document(PageSize.A4.rotate(), 20, 20, 40, 40);
PdfWriter.getInstance(doc, baos);
doc.open();
addTitrePage(doc, "GRAND LIVRE — " + numeroCompte + " " + compte.getLibelle(),
org.getNom(), dateDebut, dateFin);
PdfPTable table = new PdfPTable(6);
table.setWidthPercentage(100);
table.setWidths(new float[]{12f, 15f, 35f, 12f, 12f, 14f});
addHeaderCell(table, "Date");
addHeaderCell(table, "Pièce");
addHeaderCell(table, "Libellé");
addHeaderCell(table, "Débit");
addHeaderCell(table, "Crédit");
addHeaderCell(table, "Solde cumulé");
boolean alt = false;
for (Object[] mvt : mouvements) {
Color bg = alt ? COLOR_ROW_ALT : Color.WHITE;
addDataCell(table, DATE_FMT.format((LocalDate) mvt[0]), bg);
addDataCell(table, (String) mvt[1], bg);
addDataCell(table, (String) mvt[2], bg);
addAmountCell(table, (BigDecimal) mvt[3], bg);
addAmountCell(table, (BigDecimal) mvt[4], bg);
addAmountCell(table, (BigDecimal) mvt[5], bg);
alt = !alt;
}
if (mouvements.isEmpty()) {
PdfPCell empty = new PdfPCell(new Phrase("Aucun mouvement sur la période",
FontFactory.getFont(FontFactory.HELVETICA_OBLIQUE, 10, Color.GRAY)));
empty.setColspan(6);
empty.setPadding(10);
empty.setHorizontalAlignment(Element.ALIGN_CENTER);
table.addCell(empty);
}
doc.add(table);
addFooter(doc);
doc.close();
return baos.toByteArray();
} catch (Exception e) {
log.error("Erreur génération grand livre PDF : {}", e.getMessage(), e);
throw new RuntimeException("Erreur génération grand livre PDF", e);
}
}
// ── Utilitaires PDF ──────────────────────────────────────────────────────
private void addTitrePage(Document doc, String titre, String orgNom,
LocalDate dateDebut, LocalDate dateFin) throws DocumentException {
Font fontTitre = FontFactory.getFont(FontFactory.HELVETICA_BOLD, 16, COLOR_HEADER);
Font fontSousTitre = FontFactory.getFont(FontFactory.HELVETICA, 11, Color.DARK_GRAY);
Paragraph pTitre = new Paragraph(titre, fontTitre);
pTitre.setAlignment(Element.ALIGN_CENTER);
pTitre.setSpacingAfter(4);
doc.add(pTitre);
Paragraph pOrg = new Paragraph(orgNom, fontSousTitre);
pOrg.setAlignment(Element.ALIGN_CENTER);
doc.add(pOrg);
if (dateDebut != null && dateFin != null) {
Paragraph pPeriode = new Paragraph(
"Période : " + DATE_FMT.format(dateDebut) + " au " + DATE_FMT.format(dateFin),
FontFactory.getFont(FontFactory.HELVETICA_OBLIQUE, 10, Color.GRAY));
pPeriode.setAlignment(Element.ALIGN_CENTER);
pPeriode.setSpacingAfter(12);
doc.add(pPeriode);
}
}
private void addSectionTitle(Document doc, String titre) throws DocumentException {
Paragraph p = new Paragraph(titre,
FontFactory.getFont(FontFactory.HELVETICA_BOLD, 12, COLOR_HEADER));
p.setSpacingBefore(8);
p.setSpacingAfter(4);
doc.add(p);
}
private void addFooter(Document doc) throws DocumentException {
Paragraph footer = new Paragraph(
"Généré le " + DATE_FMT.format(LocalDate.now()) + " — UnionFlow SYSCOHADA révisé",
FontFactory.getFont(FontFactory.HELVETICA_OBLIQUE, 8, Color.GRAY));
footer.setAlignment(Element.ALIGN_RIGHT);
footer.setSpacingBefore(16);
doc.add(footer);
}
private PdfPTable creerTableau2Colonnes() throws DocumentException {
PdfPTable table = new PdfPTable(2);
table.setWidthPercentage(100);
table.setWidths(new float[]{65f, 35f});
return table;
}
private void addHeaderCell(PdfPTable table, String text) {
PdfPCell cell = new PdfPCell(new Phrase(text,
FontFactory.getFont(FontFactory.HELVETICA_BOLD, 9, COLOR_HEADER_TEXT)));
cell.setBackgroundColor(COLOR_HEADER);
cell.setPadding(6);
cell.setHorizontalAlignment(Element.ALIGN_CENTER);
table.addCell(cell);
}
private void addDataCell(PdfPTable table, String text, Color bg) {
PdfPCell cell = new PdfPCell(new Phrase(text,
FontFactory.getFont(FontFactory.HELVETICA, 9, Color.BLACK)));
cell.setBackgroundColor(bg);
cell.setPadding(5);
table.addCell(cell);
}
private void addAmountCell(PdfPTable table, BigDecimal amount, Color bg) {
String formatted = amount != null
? String.format("%,.0f XOF", amount.doubleValue())
: "0 XOF";
PdfPCell cell = new PdfPCell(new Phrase(formatted,
FontFactory.getFont(FontFactory.HELVETICA, 9, Color.BLACK)));
cell.setBackgroundColor(bg);
cell.setPadding(5);
cell.setHorizontalAlignment(Element.ALIGN_RIGHT);
table.addCell(cell);
}
private void addTotalCell(PdfPTable table, String text) {
PdfPCell cell = new PdfPCell(new Phrase(text,
FontFactory.getFont(FontFactory.HELVETICA_BOLD, 9, Color.BLACK)));
cell.setBackgroundColor(COLOR_TOTAL_ROW);
cell.setPadding(6);
table.addCell(cell);
}
// ── Calcul des totaux ─────────────────────────────────────────────────────
private Map<String, BigDecimal[]> calculerTotauxParCompte(UUID organisationId,
LocalDate dateDebut, LocalDate dateFin) {
List<EcritureComptable> ecritures = ecritureComptableRepository
.findByOrganisationAndDateRange(organisationId, dateDebut, dateFin);
Map<String, BigDecimal[]> totaux = new HashMap<>();
for (EcritureComptable ecriture : ecritures) {
if (ecriture.getLignes() == null) continue;
for (LigneEcriture ligne : ecriture.getLignes()) {
if (ligne.getCompteComptable() == null) continue;
String numero = ligne.getCompteComptable().getNumeroCompte();
totaux.computeIfAbsent(numero, k -> new BigDecimal[]{BigDecimal.ZERO, BigDecimal.ZERO});
BigDecimal debit = ligne.getMontantDebit() != null ? ligne.getMontantDebit() : BigDecimal.ZERO;
BigDecimal credit = ligne.getMontantCredit() != null ? ligne.getMontantCredit() : BigDecimal.ZERO;
totaux.get(numero)[0] = totaux.get(numero)[0].add(debit);
totaux.get(numero)[1] = totaux.get(numero)[1].add(credit);
}
}
return totaux;
}
private Organisation getOrg(UUID organisationId) {
return organisationRepository.findByIdOptional(organisationId)
.orElseThrow(() -> new NotFoundException("Organisation introuvable : " + organisationId));
}
}

View File

@@ -0,0 +1,134 @@
package dev.lions.unionflow.server.service;
import io.quarkus.mailer.MailTemplate.MailTemplateInstance;
import io.quarkus.qute.CheckedTemplate;
import io.quarkus.qute.TemplateInstance;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import io.quarkus.mailer.Mail;
import io.quarkus.mailer.Mailer;
import lombok.extern.slf4j.Slf4j;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
/**
* Service d'envoi d'emails HTML via Qute templates (Quarkus Mailer).
*
* <p>Templates : {@code src/main/resources/templates/email/}.
* Variables injectées au moment de l'appel via {@code .data(key, value)}.
*/
@Slf4j
@ApplicationScoped
public class EmailTemplateService {
private static final DateTimeFormatter DATE_FR = DateTimeFormatter.ofPattern("dd/MM/yyyy");
@Inject
Mailer mailer;
// ── Templates Qute ────────────────────────────────────────────────────────
@CheckedTemplate(basePath = "email")
static class Templates {
static native MailTemplateInstance bienvenue(
String prenom, String nom, String email,
String nomOrganisation, String lienConnexion);
static native MailTemplateInstance cotisationConfirmation(
String prenom, String nom,
String nomOrganisation, String periode,
String numeroReference, String methodePaiement,
String datePaiement, String montant);
static native MailTemplateInstance rappelCotisation(
String prenom, String nom,
String nomOrganisation, String periode,
String montant, String dateLimite, String lienPaiement);
static native MailTemplateInstance souscriptionConfirmation(
String nomAdministrateur, String nomOrganisation,
String nomFormule, String montant, String periodicite,
String dateActivation, String dateExpiration,
String maxMembres, String maxStockageMo,
boolean apiAccess, boolean supportPrioritaire);
}
// ── Méthodes d'envoi ─────────────────────────────────────────────────────
public void envoyerBienvenue(String email, String prenom, String nom,
String nomOrganisation, String lienConnexion) {
try {
Templates.bienvenue(prenom, nom, email, nomOrganisation, lienConnexion)
.to(email)
.subject("Bienvenue sur UnionFlow — " + nomOrganisation)
.send().await().indefinitely();
log.info("Email bienvenue envoyé à {}", email);
} catch (Exception e) {
log.error("Échec envoi email bienvenue à {}: {}", email, e.getMessage(), e);
}
}
public void envoyerConfirmationCotisation(String email, String prenom, String nom,
String nomOrganisation, String periode,
String numeroReference, String methodePaiement,
LocalDate datePaiement, BigDecimal montant) {
try {
Templates.cotisationConfirmation(
prenom, nom, nomOrganisation, periode,
numeroReference, methodePaiement,
datePaiement != null ? DATE_FR.format(datePaiement) : "",
String.format("%,.0f", montant.doubleValue()))
.to(email)
.subject("Confirmation de cotisation — " + periode)
.send().await().indefinitely();
log.info("Email confirmation cotisation envoyé à {}", email);
} catch (Exception e) {
log.error("Échec envoi email cotisation à {}: {}", email, e.getMessage(), e);
}
}
public void envoyerRappelCotisation(String email, String prenom, String nom,
String nomOrganisation, String periode,
BigDecimal montant, LocalDate dateLimite,
String lienPaiement) {
try {
Templates.rappelCotisation(
prenom, nom, nomOrganisation, periode,
String.format("%,.0f", montant.doubleValue()),
dateLimite != null ? DATE_FR.format(dateLimite) : "",
lienPaiement)
.to(email)
.subject("⚠️ Rappel : cotisation " + periode + " en attente")
.send().await().indefinitely();
log.info("Email rappel cotisation envoyé à {}", email);
} catch (Exception e) {
log.error("Échec envoi rappel cotisation à {}: {}", email, e.getMessage(), e);
}
}
public void envoyerConfirmationSouscription(String email, String nomAdministrateur,
String nomOrganisation, String nomFormule,
BigDecimal montant, String periodicite,
LocalDate dateActivation, LocalDate dateExpiration,
Integer maxMembres, Integer maxStockageMo,
boolean apiAccess, boolean supportPrioritaire) {
try {
Templates.souscriptionConfirmation(
nomAdministrateur, nomOrganisation, nomFormule,
String.format("%,.0f", montant.doubleValue()), periodicite,
dateActivation != null ? DATE_FR.format(dateActivation) : "",
dateExpiration != null ? DATE_FR.format(dateExpiration) : "",
maxMembres != null ? String.valueOf(maxMembres) : "Illimité",
maxStockageMo != null ? String.valueOf(maxStockageMo) : "1024",
apiAccess, supportPrioritaire)
.to(email)
.subject("✅ Souscription activée — " + nomOrganisation)
.send().await().indefinitely();
log.info("Email confirmation souscription envoyé à {}", email);
} catch (Exception e) {
log.error("Échec envoi email souscription à {}: {}", email, e.getMessage(), e);
}
}
}

View File

@@ -0,0 +1,139 @@
package dev.lions.unionflow.server.service;
import com.google.auth.oauth2.GoogleCredentials;
import com.google.firebase.FirebaseApp;
import com.google.firebase.FirebaseOptions;
import com.google.firebase.messaging.*;
import jakarta.annotation.PostConstruct;
import jakarta.enterprise.context.ApplicationScoped;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import java.io.FileInputStream;
import java.io.InputStream;
import java.util.List;
import java.util.Optional;
/**
* Service d'envoi de notifications push via Firebase Cloud Messaging (FCM).
*
* <p>Configuration requise (application-prod.properties) :
* <pre>
* firebase.service-account-key-path=/opt/unionflow/firebase-service-account.json
* </pre>
*
* <p>En dev/test, le service est désactivé si le fichier n'existe pas.
*/
@Slf4j
@ApplicationScoped
public class FirebasePushService {
@ConfigProperty(name = "firebase.service-account-key-path")
Optional<String> serviceAccountKeyPathOpt;
String serviceAccountKeyPath;
private boolean initialized = false;
@PostConstruct
void init() {
serviceAccountKeyPath = serviceAccountKeyPathOpt.orElse("");
if (serviceAccountKeyPath.isBlank()) {
log.info("Firebase FCM désactivé (firebase.service-account-key-path non configuré)");
return;
}
try {
if (FirebaseApp.getApps().isEmpty()) {
InputStream serviceAccount = new FileInputStream(serviceAccountKeyPath);
FirebaseOptions options = FirebaseOptions.builder()
.setCredentials(GoogleCredentials.fromStream(serviceAccount))
.build();
FirebaseApp.initializeApp(options);
}
initialized = true;
log.info("Firebase FCM initialisé depuis {}", serviceAccountKeyPath);
} catch (Exception e) {
log.warn("Firebase FCM non initialisé ({}): {} — les notifications push seront ignorées",
serviceAccountKeyPath, e.getMessage());
}
}
/**
* Envoie une notification push à un token FCM unique.
*
* @param token token FCM du device cible
* @param titre titre de la notification
* @param corps corps de la notification
* @param data données supplémentaires (payload JSON key/value)
* @return `true` si envoi réussi, `false` sinon
*/
public boolean envoyerNotification(String token, String titre, String corps,
java.util.Map<String, String> data) {
if (!initialized || token == null || token.isBlank()) return false;
try {
Message.Builder builder = Message.builder()
.setToken(token)
.setNotification(Notification.builder()
.setTitle(titre)
.setBody(corps)
.build());
if (data != null && !data.isEmpty()) {
builder.putAllData(data);
}
String response = FirebaseMessaging.getInstance().send(builder.build());
log.info("FCM push envoyé : messageId={}", response);
return true;
} catch (FirebaseMessagingException e) {
if (MessagingErrorCode.UNREGISTERED.equals(e.getMessagingErrorCode())
|| MessagingErrorCode.INVALID_ARGUMENT.equals(e.getMessagingErrorCode())) {
log.warn("Token FCM invalide/expiré : {}", token);
} else {
log.error("Erreur FCM pour token {}: {} ({})", token, e.getMessage(), e.getMessagingErrorCode());
}
return false;
}
}
/**
* Envoie une notification push à une liste de tokens (multicast, max 500).
*
* @return nombre de messages envoyés avec succès
*/
public int envoyerNotificationMulticast(List<String> tokens, String titre, String corps,
java.util.Map<String, String> data) {
if (!initialized || tokens == null || tokens.isEmpty()) return 0;
// FCM multicast : max 500 tokens par appel
List<String> validTokens = tokens.stream()
.filter(t -> t != null && !t.isBlank())
.limit(500)
.toList();
if (validTokens.isEmpty()) return 0;
try {
MulticastMessage.Builder builder = MulticastMessage.builder()
.addAllTokens(validTokens)
.setNotification(Notification.builder()
.setTitle(titre)
.setBody(corps)
.build());
if (data != null && !data.isEmpty()) {
builder.putAllData(data);
}
BatchResponse response = FirebaseMessaging.getInstance().sendEachForMulticast(builder.build());
log.info("FCM multicast : {}/{} envoyés avec succès", response.getSuccessCount(), validTokens.size());
return response.getSuccessCount();
} catch (FirebaseMessagingException e) {
log.error("Erreur FCM multicast : {}", e.getMessage(), e);
return 0;
}
}
public boolean isAvailable() {
return initialized;
}
}

View File

@@ -0,0 +1,253 @@
package dev.lions.unionflow.server.service;
import dev.lions.unionflow.server.api.dto.kyc.KycDossierRequest;
import dev.lions.unionflow.server.api.dto.kyc.KycDossierResponse;
import dev.lions.unionflow.server.api.enums.membre.NiveauRisqueKyc;
import dev.lions.unionflow.server.api.enums.membre.StatutKyc;
import dev.lions.unionflow.server.entity.KycDossier;
import dev.lions.unionflow.server.entity.Membre;
import dev.lions.unionflow.server.repository.KycDossierRepository;
import dev.lions.unionflow.server.repository.MembreRepository;
import dev.lions.unionflow.server.security.RlsEnabled;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import jakarta.ws.rs.NotFoundException;
import lombok.extern.slf4j.Slf4j;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import java.util.stream.Collectors;
/**
* Service KYC/AML (Know Your Customer / Anti-Money Laundering).
*
* <p>Implémente la due diligence requise par le GIABA (Groupe Intergouvernemental
* d'Action contre le Blanchiment d'Argent en Afrique de l'Ouest) et les
* instructions BCEAO sur la LCB-FT (Lutte Contre le Blanchiment et le Financement
* du Terrorisme).
*
* <p>Algorithme de score de risque :
* <ul>
* <li>PEP (Personne Exposée Politiquement) : +40 points</li>
* <li>Pièce expirée : +20 points</li>
* <li>Aucun justificatif de domicile : +15 points</li>
* <li>Pièce manquante (recto/verso) : +15 points</li>
* <li>Nationalité hors UEMOA (facteur risque géographique) : +10 points</li>
* </ul>
*/
@Slf4j
@ApplicationScoped
@RlsEnabled
public class KycAmlService {
private static final List<String> PAYS_UEMOA = List.of(
"BJ", "BF", "CI", "GW", "ML", "NE", "SN", "TG"
);
@Inject
KycDossierRepository kycDossierRepository;
@Inject
MembreRepository membreRepository;
@Inject
AuditService auditService;
/**
* Crée ou met à jour le dossier KYC d'un membre.
* Si un dossier actif existe déjà, il est archivé et remplacé.
*/
@Transactional
public KycDossierResponse soumettreOuMettreAJour(KycDossierRequest request, String operateur) {
UUID membreId = UUID.fromString(request.getMembreId());
Membre membre = membreRepository.findByIdOptional(membreId)
.orElseThrow(() -> new NotFoundException("Membre introuvable : " + request.getMembreId()));
// Archiver l'ancien dossier actif si présent
kycDossierRepository.findDossierActifByMembre(membreId).ifPresent(ancien -> {
ancien.setActif(false);
ancien.setModifiePar(operateur);
kycDossierRepository.persist(ancien);
});
KycDossier dossier = KycDossier.builder()
.membre(membre)
.typePiece(request.getTypePiece())
.numeroPiece(request.getNumeroPiece())
.dateExpirationPiece(request.getDateExpirationPiece())
.pieceIdentiteRectoFileId(request.getPieceIdentiteRectoFileId())
.pieceIdentiteVersoFileId(request.getPieceIdentiteVersoFileId())
.justifDomicileFileId(request.getJustifDomicileFileId())
.estPep(Boolean.TRUE.equals(request.getEstPep()))
.nationalite(request.getNationalite())
.notesValidateur(request.getNotesValidateur())
.statut(StatutKyc.EN_COURS)
.anneeReference(LocalDate.now().getYear())
.build();
dossier.setCreePar(operateur);
kycDossierRepository.persist(dossier);
log.info("Dossier KYC soumis pour membre {} par {}", membreId, operateur);
return toDto(dossier);
}
/**
* Évalue le score de risque LCB-FT du membre et met à jour son dossier.
*
* @param membreId l'UUID du membre
* @return le dossier KYC mis à jour avec le score et niveau de risque
*/
@Transactional
public KycDossierResponse evaluerRisque(UUID membreId) {
KycDossier dossier = kycDossierRepository.findDossierActifByMembre(membreId)
.orElseThrow(() -> new NotFoundException("Aucun dossier KYC actif pour le membre : " + membreId));
int score = calculerScore(dossier);
NiveauRisqueKyc niveau = NiveauRisqueKyc.fromScore(score);
dossier.setScoreRisque(score);
dossier.setNiveauRisque(niveau);
kycDossierRepository.persist(dossier);
if (niveau == NiveauRisqueKyc.CRITIQUE || niveau == NiveauRisqueKyc.ELEVE) {
log.warn("Membre {} : niveau risque KYC {} (score {})", membreId, niveau, score);
auditService.logKycRisqueEleve(membreId, score, niveau.name());
}
return toDto(dossier);
}
/**
* Valide manuellement un dossier KYC (approbation par un agent habilité).
*/
@Transactional
public KycDossierResponse valider(UUID dossierId, UUID validateurId, String notes, String operateur) {
KycDossier dossier = kycDossierRepository.findByIdOptional(dossierId)
.orElseThrow(() -> new NotFoundException("Dossier KYC introuvable : " + dossierId));
int score = calculerScore(dossier);
dossier.setScoreRisque(score);
dossier.setNiveauRisque(NiveauRisqueKyc.fromScore(score));
dossier.setStatut(StatutKyc.VERIFIE);
dossier.setDateVerification(LocalDateTime.now());
dossier.setValidateurId(validateurId);
dossier.setNotesValidateur(notes);
dossier.setModifiePar(operateur);
kycDossierRepository.persist(dossier);
log.info("Dossier KYC {} validé par {} (score={})", dossierId, validateurId, score);
return toDto(dossier);
}
/**
* Refuse un dossier KYC avec motif.
*/
@Transactional
public KycDossierResponse refuser(UUID dossierId, UUID validateurId, String motif, String operateur) {
KycDossier dossier = kycDossierRepository.findByIdOptional(dossierId)
.orElseThrow(() -> new NotFoundException("Dossier KYC introuvable : " + dossierId));
dossier.setStatut(StatutKyc.REFUSE);
dossier.setDateVerification(LocalDateTime.now());
dossier.setValidateurId(validateurId);
dossier.setNotesValidateur(motif);
dossier.setModifiePar(operateur);
kycDossierRepository.persist(dossier);
log.info("Dossier KYC {} refusé par {}: {}", dossierId, validateurId, motif);
return toDto(dossier);
}
public Optional<KycDossierResponse> getDossierActif(UUID membreId) {
return kycDossierRepository.findDossierActifByMembre(membreId).map(this::toDto);
}
public List<KycDossierResponse> getDossiersEnAttente() {
return kycDossierRepository.findByStatut(StatutKyc.EN_COURS)
.stream().map(this::toDto).collect(Collectors.toList());
}
public List<KycDossierResponse> getDossiersPep() {
return kycDossierRepository.findPep()
.stream().map(this::toDto).collect(Collectors.toList());
}
public List<KycDossierResponse> getPiecesExpirantDansLes30Jours() {
LocalDate limite = LocalDate.now().plusDays(30);
return kycDossierRepository.findPiecesExpirantsAvant(limite)
.stream().map(this::toDto).collect(Collectors.toList());
}
// ── Calcul score ────────────────────────────────────────────────────────────
int calculerScore(KycDossier dossier) {
int score = 0;
if (dossier.isEstPep()) {
score += 40;
}
if (dossier.isPieceExpiree()) {
score += 20;
}
if (dossier.getJustifDomicileFileId() == null || dossier.getJustifDomicileFileId().isBlank()) {
score += 15;
}
boolean rectoManquant = dossier.getPieceIdentiteRectoFileId() == null
|| dossier.getPieceIdentiteRectoFileId().isBlank();
boolean versoManquant = dossier.getPieceIdentiteVersoFileId() == null
|| dossier.getPieceIdentiteVersoFileId().isBlank();
if (rectoManquant || versoManquant) {
score += 15;
}
if (dossier.getNationalite() != null && !PAYS_UEMOA.contains(dossier.getNationalite().toUpperCase())) {
score += 10;
}
return Math.min(score, 100);
}
// ── Mapping ─────────────────────────────────────────────────────────────────
private KycDossierResponse toDto(KycDossier d) {
KycDossierResponse dto = new KycDossierResponse();
dto.setId(d.getId());
dto.setDateCreation(d.getDateCreation());
dto.setDateModification(d.getDateModification());
dto.setCreePar(d.getCreePar());
dto.setModifiePar(d.getModifiePar());
dto.setVersion(d.getVersion());
dto.setActif(d.getActif());
if (d.getMembre() != null) {
dto.setMembreId(d.getMembre().getId());
dto.setMembreNomComplet(d.getMembre().getPrenom() + " " + d.getMembre().getNom());
dto.setMembreEmail(d.getMembre().getEmail());
}
dto.setTypePiece(d.getTypePiece());
dto.setNumeroPiece(d.getNumeroPiece());
dto.setDateExpirationPiece(d.getDateExpirationPiece());
dto.setPieceIdentiteRectoFileId(d.getPieceIdentiteRectoFileId());
dto.setPieceIdentiteVersoFileId(d.getPieceIdentiteVersoFileId());
dto.setJustifDomicileFileId(d.getJustifDomicileFileId());
dto.setStatut(d.getStatut());
dto.setNiveauRisque(d.getNiveauRisque());
dto.setScoreRisque(d.getScoreRisque());
dto.setEstPep(d.isEstPep());
dto.setNationalite(d.getNationalite());
dto.setDateVerification(d.getDateVerification());
dto.setValidateurId(d.getValidateurId());
dto.setNotesValidateur(d.getNotesValidateur());
dto.setAnneeReference(d.getAnneeReference());
return dto;
}
}

View File

@@ -0,0 +1,348 @@
package dev.lions.unionflow.server.service;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import dev.lions.unionflow.server.entity.MembreOrganisation;
import dev.lions.unionflow.server.entity.Organisation;
import dev.lions.unionflow.server.repository.MembreOrganisationRepository;
import dev.lions.unionflow.server.repository.OrganisationRepository;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.text.Normalizer;
import java.time.Duration;
import java.util.List;
import java.util.UUID;
import java.util.regex.Pattern;
/**
* Service de migration one-shot : crée les Keycloak 26 Organizations correspondant
* à chaque Organisation UnionFlow, assigne les rôles standards et migre les memberships.
*
* <p>Idempotent : si {@code keycloak_org_id} est déjà renseigné pour une org,
* elle est ignorée (pas de doublon).
*
* <p>Déclenchement : endpoint admin {@code POST /api/admin/keycloak/migrer-organisations}.
*/
@Slf4j
@ApplicationScoped
public class MigrerOrganisationsVersKeycloakService {
/** Rôles Organization standards créés dans chaque Keycloak Organization. */
private static final List<String> ROLES_STANDARDS = List.of(
"ADMIN_ORGANISATION", "TRESORIER", "SECRETAIRE",
"COMMISSAIRE_COMPTES", "MEMBRE_ACTIF"
);
@ConfigProperty(name = "keycloak.admin.url", defaultValue = "http://localhost:8180")
String keycloakUrl;
@ConfigProperty(name = "keycloak.admin.username", defaultValue = "admin")
String adminUsername;
@ConfigProperty(name = "keycloak.admin.password", defaultValue = "admin")
String adminPassword;
@ConfigProperty(name = "keycloak.admin.realm", defaultValue = "unionflow")
String realm;
@Inject
OrganisationRepository organisationRepository;
@Inject
MembreOrganisationRepository membreOrganisationRepository;
private final HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
private final ObjectMapper mapper = new ObjectMapper()
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
/**
* Point d'entrée principal — migre toutes les organisations sans {@code keycloak_org_id}.
*
* @return rapport de migration
*/
@Transactional
public MigrationReport migrerToutesLesOrganisations() throws Exception {
String token = getAdminToken();
List<Organisation> orgs = organisationRepository.listAll();
int crees = 0, ignores = 0, erreurs = 0;
for (Organisation org : orgs) {
if (org.getKeycloakOrgId() != null) {
ignores++;
log.debug("Org '{}' déjà migrée (kcOrgId={}), ignorée.", org.getNom(), org.getKeycloakOrgId());
continue;
}
try {
UUID kcOrgId = creerOrganisationKeycloak(token, org);
creerRolesOrganisation(token, kcOrgId);
migrerMemberships(token, kcOrgId, org);
org.setKeycloakOrgId(kcOrgId);
organisationRepository.persist(org);
crees++;
log.info("Organisation '{}' migrée → keycloak_org_id={}", org.getNom(), kcOrgId);
} catch (Exception e) {
erreurs++;
log.error("Échec migration org '{}' (id={}): {}", org.getNom(), org.getId(), e.getMessage(), e);
}
}
return new MigrationReport(orgs.size(), crees, ignores, erreurs);
}
// ── Création Organization Keycloak ──────────────────────────────────────────
private UUID creerOrganisationKeycloak(String token, Organisation org) throws Exception {
ObjectNode body = mapper.createObjectNode();
body.put("name", org.getNom());
body.put("alias", slugify(org.getNom()));
body.put("enabled", true);
ObjectNode attrs = mapper.createObjectNode();
attrs.putArray("unionflow_id").add(org.getId().toString());
attrs.putArray("type_organisation").add(org.getTypeOrganisation() != null ? org.getTypeOrganisation() : "");
if (org.getCategorieType() != null) {
attrs.putArray("categorie").add(org.getCategorieType());
}
body.set("attributes", attrs);
// Ajouter le domaine email si disponible
if (org.getEmail() != null) {
String domaine = org.getEmail().contains("@") ? org.getEmail().split("@")[1] : "";
if (!domaine.isBlank()) {
ArrayNode domains = mapper.createArrayNode();
ObjectNode domainObj = mapper.createObjectNode();
domainObj.put("name", domaine);
domainObj.put("verified", false);
domains.add(domainObj);
body.set("domains", domains);
}
}
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(keycloakUrl + "/admin/realms/" + realm + "/organizations"))
.header("Authorization", "Bearer " + token)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(mapper.writeValueAsString(body)))
.timeout(Duration.ofSeconds(10))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
// 201 Created → Location header contient l'URL avec l'ID
if (response.statusCode() == 201) {
String location = response.headers().firstValue("Location").orElseThrow(
() -> new RuntimeException("Keycloak 201 mais sans header Location pour org: " + org.getNom()));
String kcOrgId = location.substring(location.lastIndexOf('/') + 1);
return UUID.fromString(kcOrgId);
}
// 409 = alias déjà pris → chercher l'org existante par alias
if (response.statusCode() == 409) {
log.warn("Organisation '{}' déjà présente dans Keycloak (409), recherche par alias.", org.getNom());
return chercherOrganisationParAlias(token, slugify(org.getNom()));
}
throw new RuntimeException("Échec création Keycloak Org '" + org.getNom()
+ "' (HTTP " + response.statusCode() + "): " + response.body());
}
private UUID chercherOrganisationParAlias(String token, String alias) throws Exception {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(keycloakUrl + "/admin/realms/" + realm + "/organizations?search=" + alias + "&max=10"))
.header("Authorization", "Bearer " + token)
.GET()
.timeout(Duration.ofSeconds(10))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
throw new RuntimeException("Impossible de rechercher l'org par alias '" + alias + "'");
}
var results = mapper.readTree(response.body());
for (var node : results) {
if (alias.equals(node.path("alias").asText())) {
return UUID.fromString(node.path("id").asText());
}
}
throw new RuntimeException("Organisation avec alias '" + alias + "' introuvable dans Keycloak.");
}
// ── Création rôles standards ────────────────────────────────────────────────
private void creerRolesOrganisation(String token, UUID kcOrgId) throws Exception {
for (String roleName : ROLES_STANDARDS) {
ObjectNode roleBody = mapper.createObjectNode();
roleBody.put("name", roleName);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(keycloakUrl + "/admin/realms/" + realm
+ "/organizations/" + kcOrgId + "/roles"))
.header("Authorization", "Bearer " + token)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(mapper.writeValueAsString(roleBody)))
.timeout(Duration.ofSeconds(10))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 201 && response.statusCode() != 409) {
log.warn("Impossible de créer le rôle '{}' pour kcOrgId={} (HTTP {}): {}",
roleName, kcOrgId, response.statusCode(), response.body());
}
}
}
// ── Migration memberships ───────────────────────────────────────────────────
private void migrerMemberships(String token, UUID kcOrgId, Organisation org) {
List<MembreOrganisation> memberships = membreOrganisationRepository
.find("organisation.id = ?1 AND actif = true", org.getId())
.list();
for (MembreOrganisation mo : memberships) {
if (mo.getMembre() == null || mo.getMembre().getKeycloakId() == null) {
continue;
}
String kcUserId = mo.getMembre().getKeycloakId().toString();
try {
ajouterMembreKeycloakOrg(token, kcOrgId, kcUserId);
if (mo.getRoleOrg() != null && ROLES_STANDARDS.contains(mo.getRoleOrg())) {
assignerRoleOrganisation(token, kcOrgId, kcUserId, mo.getRoleOrg());
}
} catch (Exception e) {
log.warn("Impossible de migrer le membership keycloakId={} → kcOrg={}: {}",
kcUserId, kcOrgId, e.getMessage());
}
}
}
private void ajouterMembreKeycloakOrg(String token, UUID kcOrgId, String kcUserId) throws Exception {
ObjectNode body = mapper.createObjectNode();
body.put("id", kcUserId);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(keycloakUrl + "/admin/realms/" + realm
+ "/organizations/" + kcOrgId + "/members"))
.header("Authorization", "Bearer " + token)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(mapper.writeValueAsString(body)))
.timeout(Duration.ofSeconds(10))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 201 && response.statusCode() != 409) {
throw new RuntimeException("HTTP " + response.statusCode() + ": " + response.body());
}
}
private void assignerRoleOrganisation(String token, UUID kcOrgId, String kcUserId,
String roleName) throws Exception {
// 1. Récupérer l'ID du rôle
HttpRequest getRoles = HttpRequest.newBuilder()
.uri(URI.create(keycloakUrl + "/admin/realms/" + realm
+ "/organizations/" + kcOrgId + "/roles"))
.header("Authorization", "Bearer " + token)
.GET()
.timeout(Duration.ofSeconds(10))
.build();
HttpResponse<String> rolesResponse = httpClient.send(getRoles, HttpResponse.BodyHandlers.ofString());
if (rolesResponse.statusCode() != 200) return;
var roles = mapper.readTree(rolesResponse.body());
String roleId = null;
for (var role : roles) {
if (roleName.equals(role.path("name").asText())) {
roleId = role.path("id").asText();
break;
}
}
if (roleId == null) return;
// 2. Assigner le rôle au membre
ArrayNode assignBody = mapper.createArrayNode();
ObjectNode roleRef = mapper.createObjectNode();
roleRef.put("id", roleId);
roleRef.put("name", roleName);
assignBody.add(roleRef);
HttpRequest assignRequest = HttpRequest.newBuilder()
.uri(URI.create(keycloakUrl + "/admin/realms/" + realm
+ "/organizations/" + kcOrgId + "/members/" + kcUserId + "/roles"))
.header("Authorization", "Bearer " + token)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(mapper.writeValueAsString(assignBody)))
.timeout(Duration.ofSeconds(10))
.build();
HttpResponse<String> assignResponse = httpClient.send(assignRequest, HttpResponse.BodyHandlers.ofString());
if (assignResponse.statusCode() != 201 && assignResponse.statusCode() != 204) {
log.warn("Impossible d'assigner le rôle '{}' à l'utilisateur {} (HTTP {})",
roleName, kcUserId, assignResponse.statusCode());
}
}
// ── Auth admin ──────────────────────────────────────────────────────────────
private String getAdminToken() throws Exception {
String body = "client_id=admin-cli"
+ "&username=" + java.net.URLEncoder.encode(adminUsername, java.nio.charset.StandardCharsets.UTF_8)
+ "&password=" + java.net.URLEncoder.encode(adminPassword, java.nio.charset.StandardCharsets.UTF_8)
+ "&grant_type=password";
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(keycloakUrl + "/realms/master/protocol/openid-connect/token"))
.header("Content-Type", "application/x-www-form-urlencoded")
.POST(HttpRequest.BodyPublishers.ofString(body))
.timeout(Duration.ofSeconds(10))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
throw new RuntimeException("Échec auth admin Keycloak (HTTP " + response.statusCode() + ")");
}
return mapper.readTree(response.body()).get("access_token").asText();
}
// ── Utilitaires ─────────────────────────────────────────────────────────────
private static final Pattern NON_ALPHANUMERIC = Pattern.compile("[^a-z0-9-]");
private static final Pattern MULTIPLE_DASHES = Pattern.compile("-{2,}");
static String slugify(String input) {
if (input == null) return "org-" + UUID.randomUUID().toString().substring(0, 8);
String normalized = Normalizer.normalize(input.toLowerCase(), Normalizer.Form.NFD)
.replaceAll("\\p{InCombiningDiacriticalMarks}+", "");
String slug = NON_ALPHANUMERIC.matcher(normalized.replace(' ', '-')).replaceAll("");
slug = MULTIPLE_DASHES.matcher(slug).replaceAll("-").replaceAll("^-|-$", "");
return slug.isBlank() ? "org-" + UUID.randomUUID().toString().substring(0, 8) : slug;
}
// ── Rapport ─────────────────────────────────────────────────────────────────
public record MigrationReport(int total, int crees, int ignores, int erreurs) {
public boolean success() {
return erreurs == 0;
}
}
}

View File

@@ -0,0 +1,207 @@
package dev.lions.unionflow.server.service.mutuelle;
import dev.lions.unionflow.server.api.enums.mutuelle.epargne.StatutCompteEpargne;
import dev.lions.unionflow.server.api.enums.mutuelle.epargne.TypeTransactionEpargne;
import dev.lions.unionflow.server.api.enums.mutuelle.parts.TypeTransactionPartsSociales;
import dev.lions.unionflow.server.api.enums.wave.StatutTransactionWave;
import dev.lions.unionflow.server.entity.mutuelle.ParametresFinanciersMutuelle;
import dev.lions.unionflow.server.entity.mutuelle.epargne.CompteEpargne;
import dev.lions.unionflow.server.entity.mutuelle.epargne.TransactionEpargne;
import dev.lions.unionflow.server.entity.mutuelle.parts.ComptePartsSociales;
import dev.lions.unionflow.server.entity.mutuelle.parts.TransactionPartsSociales;
import dev.lions.unionflow.server.repository.mutuelle.ParametresFinanciersMutuellRepository;
import dev.lions.unionflow.server.repository.mutuelle.epargne.CompteEpargneRepository;
import dev.lions.unionflow.server.repository.mutuelle.epargne.TransactionEpargneRepository;
import dev.lions.unionflow.server.repository.mutuelle.parts.ComptePartsSocialesRepository;
import dev.lions.unionflow.server.repository.mutuelle.parts.TransactionPartsSocialesRepository;
import io.quarkus.scheduler.Scheduled;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import org.jboss.logging.Logger;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* Calcul automatique des intérêts sur épargne et des dividendes sur parts sociales.
*
* <p>Le scheduler tourne chaque jour à 02:00 et vérifie si un calcul est dû
* selon la périodicité configurée par organisation.
*/
@ApplicationScoped
public class InteretsEpargneService {
private static final Logger LOG = Logger.getLogger(InteretsEpargneService.class);
@Inject ParametresFinanciersMutuellRepository parametresRepo;
@Inject CompteEpargneRepository compteEpargneRepository;
@Inject TransactionEpargneRepository transactionEpargneRepository;
@Inject ComptePartsSocialesRepository comptePartsSocialesRepository;
@Inject TransactionPartsSocialesRepository transactionPartsSocialesRepository;
/**
* Scheduler quotidien — calcule les intérêts pour toutes les organisations dont
* la date prochaine_calcul_interets est aujourd'hui ou dans le passé.
*/
@Scheduled(cron = "0 0 2 * * ?")
@Transactional
public void calculerInteretsScheduled() {
LocalDate aujourd_hui = LocalDate.now();
List<ParametresFinanciersMutuelle> tous = parametresRepo.listAll();
for (ParametresFinanciersMutuelle params : tous) {
if (params.getProchaineCalculInterets() != null
&& !params.getProchaineCalculInterets().isAfter(aujourd_hui)) {
try {
calculerInteretsPourOrg(params);
} catch (Exception e) {
LOG.errorf("Erreur calcul intérêts org %s: %s",
params.getOrganisation().getId(), e.getMessage());
}
}
}
}
/**
* Déclenchement manuel par un admin pour une organisation donnée.
* @return résumé : nombre de comptes traités, montant total crédité
*/
@Transactional
public Map<String, Object> calculerManuellement(UUID orgId) {
ParametresFinanciersMutuelle params = parametresRepo.findByOrganisation(orgId)
.orElseThrow(() -> new IllegalArgumentException(
"Aucun paramètre financier configuré pour cette organisation. "
+ "Créez d'abord les paramètres via POST /api/v1/mutuelle/parametres-financiers."));
return calculerInteretsPourOrg(params);
}
// ─── Internal ─────────────────────────────────────────────────────────────
Map<String, Object> calculerInteretsPourOrg(ParametresFinanciersMutuelle params) {
UUID orgId = params.getOrganisation().getId();
LOG.infof("Calcul intérêts org %s — taux épargne=%.4f, taux parts=%.4f",
orgId, params.getTauxInteretAnnuelEpargne(), params.getTauxDividendePartsAnnuel());
int nbEpargne = calculerInteretsEpargne(params, orgId);
int nbParts = calculerDividendesParts(params, orgId);
// Mise à jour des dates
params.setDernierCalculInterets(LocalDate.now());
params.setDernierNbComptesTraites(nbEpargne + nbParts);
params.setProchaineCalculInterets(prochaineDateCalcul(params));
LOG.infof("Calcul terminé org %s — %d comptes épargne, %d comptes parts", orgId, nbEpargne, nbParts);
return Map.of(
"organisationId", orgId.toString(),
"comptesEpargneTraites", nbEpargne,
"comptesPartsTraites", nbParts,
"dateCalcul", LocalDate.now().toString(),
"prochaineCalcul", params.getProchaineCalculInterets().toString()
);
}
private int calculerInteretsEpargne(ParametresFinanciersMutuelle params, UUID orgId) {
if (params.getTauxInteretAnnuelEpargne().compareTo(BigDecimal.ZERO) == 0) return 0;
List<CompteEpargne> comptes = compteEpargneRepository
.find("organisation.id = ?1 AND statut = ?2 AND actif = true",
orgId, StatutCompteEpargne.ACTIF)
.list();
BigDecimal tauxPeriodique = calculerTauxPeriodique(
params.getTauxInteretAnnuelEpargne(), params.getPeriodiciteCalcul());
BigDecimal seuil = params.getSeuilMinEpargneInterets() != null
? params.getSeuilMinEpargneInterets() : BigDecimal.ZERO;
int count = 0;
for (CompteEpargne compte : comptes) {
BigDecimal solde = compte.getSoldeActuel().subtract(compte.getSoldeBloque());
if (solde.compareTo(seuil) <= 0) continue;
BigDecimal interets = solde.multiply(tauxPeriodique).setScale(0, RoundingMode.HALF_UP);
if (interets.compareTo(BigDecimal.ZERO) <= 0) continue;
compte.setSoldeActuel(compte.getSoldeActuel().add(interets));
compte.setDateDerniereTransaction(LocalDate.now());
TransactionEpargne tx = TransactionEpargne.builder()
.compte(compte)
.type(TypeTransactionEpargne.PAIEMENT_INTERETS)
.montant(interets)
.soldeAvant(solde)
.soldeApres(compte.getSoldeActuel())
.motif("Intérêts " + params.getPeriodiciteCalcul().toLowerCase()
+ " — taux " + params.getTauxInteretAnnuelEpargne()
.multiply(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP) + "%/an")
.dateTransaction(LocalDateTime.now())
.statutExecution(StatutTransactionWave.REUSSIE)
.origineFonds("Calcul automatique intérêts")
.build();
transactionEpargneRepository.persist(tx);
count++;
}
return count;
}
private int calculerDividendesParts(ParametresFinanciersMutuelle params, UUID orgId) {
if (params.getTauxDividendePartsAnnuel().compareTo(BigDecimal.ZERO) == 0) return 0;
List<ComptePartsSociales> comptes = comptePartsSocialesRepository.findByOrganisation(orgId);
BigDecimal tauxPeriodique = calculerTauxPeriodique(
params.getTauxDividendePartsAnnuel(), params.getPeriodiciteCalcul());
int count = 0;
for (ComptePartsSociales compte : comptes) {
if (compte.getMontantTotal().compareTo(BigDecimal.ZERO) <= 0) continue;
BigDecimal dividende = compte.getMontantTotal()
.multiply(tauxPeriodique).setScale(0, RoundingMode.HALF_UP);
if (dividende.compareTo(BigDecimal.ZERO) <= 0) continue;
// Dividende: enregistré comme transaction (NB: ne modifie pas le nombre de parts)
int partsRef = 1; // transaction symbolique — montant est le vrai indicateur
TransactionPartsSociales tx = TransactionPartsSociales.builder()
.compte(compte)
.typeTransaction(TypeTransactionPartsSociales.PAIEMENT_DIVIDENDE)
.nombreParts(partsRef)
.montant(dividende)
.soldePartsAvant(compte.getNombreParts())
.soldePartsApres(compte.getNombreParts())
.motif("Dividende " + params.getPeriodiciteCalcul().toLowerCase()
+ " — taux " + params.getTauxDividendePartsAnnuel()
.multiply(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP) + "%/an")
.dateTransaction(LocalDateTime.now())
.build();
compte.setTotalDividendesRecus(compte.getTotalDividendesRecus().add(dividende));
compte.setDateDerniereOperation(LocalDate.now());
transactionPartsSocialesRepository.persist(tx);
count++;
}
return count;
}
/** Taux périodique = taux annuel / nombre de périodes par an */
private BigDecimal calculerTauxPeriodique(BigDecimal tauxAnnuel, String periodicite) {
int diviseur = switch (periodicite.toUpperCase()) {
case "MENSUEL" -> 12;
case "TRIMESTRIEL" -> 4;
default -> 1; // ANNUEL
};
return tauxAnnuel.divide(BigDecimal.valueOf(diviseur), 8, RoundingMode.HALF_UP);
}
private LocalDate prochaineDateCalcul(ParametresFinanciersMutuelle params) {
LocalDate base = LocalDate.now();
return switch (params.getPeriodiciteCalcul().toUpperCase()) {
case "MENSUEL" -> base.plusMonths(1);
case "TRIMESTRIEL" -> base.plusMonths(3);
default -> base.plusYears(1);
};
}
}

View File

@@ -0,0 +1,66 @@
package dev.lions.unionflow.server.service.mutuelle;
import dev.lions.unionflow.server.api.dto.mutuelle.financier.ParametresFinanciersMutuellRequest;
import dev.lions.unionflow.server.api.dto.mutuelle.financier.ParametresFinanciersMutuellResponse;
import dev.lions.unionflow.server.entity.Organisation;
import dev.lions.unionflow.server.entity.mutuelle.ParametresFinanciersMutuelle;
import dev.lions.unionflow.server.repository.OrganisationRepository;
import dev.lions.unionflow.server.repository.mutuelle.ParametresFinanciersMutuellRepository;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import jakarta.ws.rs.NotFoundException;
import java.time.LocalDate;
import java.util.UUID;
@ApplicationScoped
public class ParametresFinanciersService {
@Inject ParametresFinanciersMutuellRepository repo;
@Inject OrganisationRepository organisationRepository;
public ParametresFinanciersMutuellResponse getByOrganisation(UUID orgId) {
ParametresFinanciersMutuelle p = repo.findByOrganisation(orgId)
.orElseThrow(() -> new NotFoundException("Aucun paramètre financier pour cette organisation."));
return toDto(p);
}
@Transactional
public ParametresFinanciersMutuellResponse creerOuMettrAJour(ParametresFinanciersMutuellRequest req) {
Organisation org = organisationRepository.findByIdOptional(UUID.fromString(req.getOrganisationId()))
.orElseThrow(() -> new NotFoundException("Organisation introuvable: " + req.getOrganisationId()));
ParametresFinanciersMutuelle params = repo.findByOrganisation(org.getId())
.orElseGet(() -> ParametresFinanciersMutuelle.builder().organisation(org).build());
params.setValeurNominaleParDefaut(req.getValeurNominaleParDefaut());
params.setTauxInteretAnnuelEpargne(req.getTauxInteretAnnuelEpargne());
params.setTauxDividendePartsAnnuel(req.getTauxDividendePartsAnnuel());
params.setPeriodiciteCalcul(req.getPeriodiciteCalcul().toUpperCase());
if (req.getSeuilMinEpargneInterets() != null) {
params.setSeuilMinEpargneInterets(req.getSeuilMinEpargneInterets());
}
if (params.getProchaineCalculInterets() == null) {
params.setProchaineCalculInterets(LocalDate.now().plusMonths(1));
}
repo.persist(params);
return toDto(params);
}
private ParametresFinanciersMutuellResponse toDto(ParametresFinanciersMutuelle p) {
return ParametresFinanciersMutuellResponse.builder()
.organisationId(p.getOrganisation().getId().toString())
.organisationNom(p.getOrganisation().getNom())
.valeurNominaleParDefaut(p.getValeurNominaleParDefaut())
.tauxInteretAnnuelEpargne(p.getTauxInteretAnnuelEpargne())
.tauxDividendePartsAnnuel(p.getTauxDividendePartsAnnuel())
.periodiciteCalcul(p.getPeriodiciteCalcul())
.seuilMinEpargneInterets(p.getSeuilMinEpargneInterets())
.prochaineCalculInterets(p.getProchaineCalculInterets())
.dernierCalculInterets(p.getDernierCalculInterets())
.dernierNbComptesTraites(p.getDernierNbComptesTraites())
.build();
}
}

View File

@@ -0,0 +1,336 @@
package dev.lions.unionflow.server.service.mutuelle;
import com.lowagie.text.*;
import com.lowagie.text.Font;
import com.lowagie.text.pdf.*;
import dev.lions.unionflow.server.entity.mutuelle.epargne.CompteEpargne;
import dev.lions.unionflow.server.entity.mutuelle.epargne.TransactionEpargne;
import dev.lions.unionflow.server.entity.mutuelle.parts.ComptePartsSociales;
import dev.lions.unionflow.server.entity.mutuelle.parts.TransactionPartsSociales;
import dev.lions.unionflow.server.repository.mutuelle.epargne.CompteEpargneRepository;
import dev.lions.unionflow.server.repository.mutuelle.epargne.TransactionEpargneRepository;
import dev.lions.unionflow.server.repository.mutuelle.parts.ComptePartsSocialesRepository;
import dev.lions.unionflow.server.repository.mutuelle.parts.TransactionPartsSocialesRepository;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.ws.rs.NotFoundException;
import java.awt.Color;
import java.io.ByteArrayOutputStream;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;
/**
* Génère les relevés de compte en PDF (OpenPDF).
* Deux types : relevé épargne, relevé parts sociales.
*/
@ApplicationScoped
public class ReleveComptePdfService {
private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("dd/MM/yyyy");
private static final DateTimeFormatter DATETIME_FMT = DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm");
private static final Color BLEU_UNIONFLOW = new Color(30, 90, 160);
private static final Color GRIS_ENTETE = new Color(240, 240, 245);
@Inject CompteEpargneRepository compteEpargneRepository;
@Inject TransactionEpargneRepository transactionEpargneRepository;
@Inject ComptePartsSocialesRepository comptePartsSocialesRepository;
@Inject TransactionPartsSocialesRepository transactionPartsSocialesRepository;
// ─── Relevé Épargne ────────────────────────────────────────────────────────
public byte[] genererReleveEpargne(UUID compteId, LocalDate dateDebut, LocalDate dateFin) {
CompteEpargne compte = compteEpargneRepository.findByIdOptional(compteId)
.orElseThrow(() -> new NotFoundException("Compte épargne introuvable: " + compteId));
List<TransactionEpargne> txs = transactionEpargneRepository
.find("compte.id = ?1 ORDER BY dateTransaction ASC", compteId)
.list();
if (dateDebut != null) {
txs = txs.stream()
.filter(t -> !t.getDateTransaction().toLocalDate().isBefore(dateDebut))
.collect(Collectors.toList());
}
if (dateFin != null) {
txs = txs.stream()
.filter(t -> !t.getDateTransaction().toLocalDate().isAfter(dateFin))
.collect(Collectors.toList());
}
return buildPdfEpargne(compte, txs, dateDebut, dateFin);
}
// ─── Relevé Parts Sociales ─────────────────────────────────────────────────
public byte[] genererReleveParts(UUID compteId, LocalDate dateDebut, LocalDate dateFin) {
ComptePartsSociales compte = comptePartsSocialesRepository.findByIdOptional(compteId)
.orElseThrow(() -> new NotFoundException("Compte parts sociales introuvable: " + compteId));
List<TransactionPartsSociales> txs = transactionPartsSocialesRepository.findByCompte(compteId);
// findByCompte is DESC — reverse for statement order
java.util.Collections.reverse(txs);
if (dateDebut != null) {
txs = txs.stream()
.filter(t -> !t.getDateTransaction().toLocalDate().isBefore(dateDebut))
.collect(Collectors.toList());
}
if (dateFin != null) {
txs = txs.stream()
.filter(t -> !t.getDateTransaction().toLocalDate().isAfter(dateFin))
.collect(Collectors.toList());
}
return buildPdfParts(compte, txs, dateDebut, dateFin);
}
// ─── PDF builders ──────────────────────────────────────────────────────────
private byte[] buildPdfEpargne(CompteEpargne compte, List<TransactionEpargne> txs,
LocalDate dateDebut, LocalDate dateFin) {
try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
Document doc = new Document(PageSize.A4, 40, 40, 60, 40);
PdfWriter.getInstance(doc, baos);
doc.open();
addHeader(doc, compte.getOrganisation() != null ? compte.getOrganisation().getNom() : "UnionFlow",
"RELEVÉ DE COMPTE ÉPARGNE");
addInfoBlock(doc, new String[][]{
{"Numéro de compte", compte.getNumeroCompte()},
{"Type de compte", compte.getTypeCompte() != null ? compte.getTypeCompte().name() : ""},
{"Titulaire", membreNom(compte)},
{"Période", formatPeriode(dateDebut, dateFin)},
{"Date d'édition", LocalDate.now().format(DATE_FMT)},
{"Solde actuel", formatMontant(compte.getSoldeActuel())}
});
// Solde d'ouverture de la période
BigDecimal soldeOuverture = txs.isEmpty() ? compte.getSoldeActuel()
: txs.get(0).getSoldeAvant();
PdfPTable table = createTable(new float[]{2f, 3f, 2.5f, 2.5f, 2.5f},
new String[]{"Date", "Libellé", "Débit", "Crédit", "Solde"});
addLigneTotal(table, "Solde d'ouverture", null, null, soldeOuverture);
for (TransactionEpargne tx : txs) {
boolean isDebit = isDebitEpargne(tx);
table.addCell(cell(tx.getDateTransaction().format(DATE_FMT), false));
table.addCell(cell(tx.getMotif() != null ? tx.getMotif() : tx.getType().name(), false));
table.addCell(cellAmount(isDebit ? tx.getMontant() : null, true));
table.addCell(cellAmount(!isDebit ? tx.getMontant() : null, false));
table.addCell(cellAmount(tx.getSoldeApres(), false));
}
doc.add(table);
addSoldeFinal(doc, compte.getSoldeActuel());
addFooter(doc);
doc.close();
return baos.toByteArray();
} catch (Exception e) {
throw new RuntimeException("Erreur génération relevé épargne PDF: " + e.getMessage(), e);
}
}
private byte[] buildPdfParts(ComptePartsSociales compte, List<TransactionPartsSociales> txs,
LocalDate dateDebut, LocalDate dateFin) {
try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
Document doc = new Document(PageSize.A4, 40, 40, 60, 40);
PdfWriter.getInstance(doc, baos);
doc.open();
addHeader(doc, compte.getOrganisation() != null ? compte.getOrganisation().getNom() : "UnionFlow",
"RELEVÉ DE PARTS SOCIALES");
addInfoBlock(doc, new String[][]{
{"Numéro de compte", compte.getNumeroCompte()},
{"Titulaire", compte.getMembre() != null
? compte.getMembre().getNom() + " " + compte.getMembre().getPrenom() : ""},
{"Valeur nominale", formatMontant(compte.getValeurNominale()) + " / part"},
{"Parts détenues", String.valueOf(compte.getNombreParts())},
{"Capital total", formatMontant(compte.getMontantTotal())},
{"Dividendes reçus", formatMontant(compte.getTotalDividendesRecus())},
{"Période", formatPeriode(dateDebut, dateFin)},
{"Date d'édition", LocalDate.now().format(DATE_FMT)}
});
PdfPTable table = createTable(new float[]{2f, 3f, 2f, 2.5f, 2.5f, 2.5f},
new String[]{"Date", "Libellé", "Parts", "Montant", "Avant", "Après"});
for (TransactionPartsSociales tx : txs) {
table.addCell(cell(tx.getDateTransaction().format(DATE_FMT), false));
table.addCell(cell(tx.getMotif() != null ? tx.getMotif() : tx.getTypeTransaction().getLibelle(), false));
table.addCell(cell(String.valueOf(tx.getNombreParts()), false));
table.addCell(cellAmount(tx.getMontant(), false));
table.addCell(cell(String.valueOf(tx.getSoldePartsAvant()), false));
table.addCell(cell(String.valueOf(tx.getSoldePartsApres()), false));
}
doc.add(table);
addFooter(doc);
doc.close();
return baos.toByteArray();
} catch (Exception e) {
throw new RuntimeException("Erreur génération relevé parts sociales PDF: " + e.getMessage(), e);
}
}
// ─── PDF helpers ───────────────────────────────────────────────────────────
private void addHeader(Document doc, String orgNom, String titre) throws DocumentException {
Font fontOrg = FontFactory.getFont(FontFactory.HELVETICA_BOLD, 16, BLEU_UNIONFLOW);
Font fontTitre = FontFactory.getFont(FontFactory.HELVETICA_BOLD, 13);
Font fontSub = FontFactory.getFont(FontFactory.HELVETICA, 9, Color.GRAY);
Paragraph pOrg = new Paragraph(orgNom, fontOrg);
pOrg.setAlignment(Element.ALIGN_CENTER);
doc.add(pOrg);
Paragraph pTitre = new Paragraph(titre, fontTitre);
pTitre.setAlignment(Element.ALIGN_CENTER);
pTitre.setSpacingBefore(4);
doc.add(pTitre);
Paragraph pSub = new Paragraph("Édité via UnionFlow · " + LocalDateTime.now().format(DATETIME_FMT), fontSub);
pSub.setAlignment(Element.ALIGN_CENTER);
pSub.setSpacingAfter(16);
doc.add(pSub);
// Separator line
PdfPTable sep = new PdfPTable(1);
sep.setWidthPercentage(100);
PdfPCell line = new PdfPCell(new Phrase(" "));
line.setBorderColor(BLEU_UNIONFLOW);
line.setBorderWidthBottom(1.5f);
line.setBorderWidthTop(0);
line.setBorderWidthLeft(0);
line.setBorderWidthRight(0);
line.setPaddingBottom(4);
sep.addCell(line);
doc.add(sep);
doc.add(Chunk.NEWLINE);
}
private void addInfoBlock(Document doc, String[][] lignes) throws DocumentException {
PdfPTable t = new PdfPTable(2);
t.setWidthPercentage(60);
t.setHorizontalAlignment(Element.ALIGN_LEFT);
t.setWidths(new float[]{2f, 3f});
Font fontLabel = FontFactory.getFont(FontFactory.HELVETICA_BOLD, 9);
Font fontVal = FontFactory.getFont(FontFactory.HELVETICA, 9);
for (String[] row : lignes) {
PdfPCell cLabel = new PdfPCell(new Phrase(row[0], fontLabel));
cLabel.setBorder(Rectangle.NO_BORDER);
cLabel.setPadding(3);
PdfPCell cVal = new PdfPCell(new Phrase(row[1], fontVal));
cVal.setBorder(Rectangle.NO_BORDER);
cVal.setPadding(3);
t.addCell(cLabel);
t.addCell(cVal);
}
t.setSpacingAfter(12);
doc.add(t);
}
private PdfPTable createTable(float[] widths, String[] headers) throws DocumentException {
PdfPTable table = new PdfPTable(widths.length);
table.setWidthPercentage(100);
table.setWidths(widths);
Font fontHeader = FontFactory.getFont(FontFactory.HELVETICA_BOLD, 9, Color.WHITE);
for (String h : headers) {
PdfPCell cell = new PdfPCell(new Phrase(h, fontHeader));
cell.setBackgroundColor(BLEU_UNIONFLOW);
cell.setPadding(5);
cell.setHorizontalAlignment(Element.ALIGN_CENTER);
table.addCell(cell);
}
table.setHeaderRows(1);
return table;
}
private PdfPCell cell(String text, boolean bold) {
Font f = bold
? FontFactory.getFont(FontFactory.HELVETICA_BOLD, 8)
: FontFactory.getFont(FontFactory.HELVETICA, 8);
PdfPCell c = new PdfPCell(new Phrase(text != null ? text : "", f));
c.setPadding(4);
c.setBorderColor(Color.LIGHT_GRAY);
return c;
}
private PdfPCell cellAmount(BigDecimal amount, boolean isDebit) {
if (amount == null || amount.compareTo(BigDecimal.ZERO) == 0) {
PdfPCell c = cell("", false);
c.setHorizontalAlignment(Element.ALIGN_RIGHT);
return c;
}
Font f = FontFactory.getFont(FontFactory.HELVETICA, 8,
isDebit ? new Color(180, 0, 0) : new Color(0, 130, 0));
PdfPCell c = new PdfPCell(new Phrase(formatMontant(amount), f));
c.setPadding(4);
c.setHorizontalAlignment(Element.ALIGN_RIGHT);
c.setBorderColor(Color.LIGHT_GRAY);
return c;
}
private void addLigneTotal(PdfPTable table, String label, BigDecimal debit,
BigDecimal credit, BigDecimal solde) {
Font f = FontFactory.getFont(FontFactory.HELVETICA_BOLD, 8);
PdfPCell cLabel = new PdfPCell(new Phrase(label, f));
cLabel.setColspan(2);
cLabel.setBackgroundColor(GRIS_ENTETE);
cLabel.setPadding(4);
table.addCell(cLabel);
table.addCell(cellAmount(debit, true));
table.addCell(cellAmount(credit, false));
PdfPCell cSolde = cellAmount(solde, false);
cSolde.setBackgroundColor(GRIS_ENTETE);
table.addCell(cSolde);
}
private void addSoldeFinal(Document doc, BigDecimal solde) throws DocumentException {
Font f = FontFactory.getFont(FontFactory.HELVETICA_BOLD, 10, BLEU_UNIONFLOW);
Paragraph p = new Paragraph("Solde final : " + formatMontant(solde), f);
p.setAlignment(Element.ALIGN_RIGHT);
p.setSpacingBefore(8);
doc.add(p);
}
private void addFooter(Document doc) throws DocumentException {
Font f = FontFactory.getFont(FontFactory.HELVETICA, 8, Color.GRAY);
Paragraph p = new Paragraph(
"Document généré automatiquement par UnionFlow — confidentiel.", f);
p.setAlignment(Element.ALIGN_CENTER);
p.setSpacingBefore(20);
doc.add(p);
}
private String formatMontant(BigDecimal m) {
if (m == null) return "0 XOF";
return String.format("%,.0f XOF", m.doubleValue());
}
private String formatPeriode(LocalDate debut, LocalDate fin) {
if (debut == null && fin == null) return "Toutes opérations";
if (debut == null) return "jusqu'au " + fin.format(DATE_FMT);
if (fin == null) return "depuis le " + debut.format(DATE_FMT);
return debut.format(DATE_FMT) + " au " + fin.format(DATE_FMT);
}
private String membreNom(CompteEpargne compte) {
if (compte.getMembre() == null) return "";
return compte.getMembre().getNom() + " " + compte.getMembre().getPrenom();
}
private boolean isDebitEpargne(TransactionEpargne tx) {
return switch (tx.getType()) {
case RETRAIT, PRELEVEMENT_FRAIS, TRANSFERT_SORTANT, REMBOURSEMENT_CREDIT, RETENUE_GARANTIE -> true;
default -> false;
};
}
}

View File

@@ -0,0 +1,200 @@
package dev.lions.unionflow.server.service.mutuelle.parts;
import dev.lions.unionflow.server.api.dto.mutuelle.parts.ComptePartsSocialesRequest;
import dev.lions.unionflow.server.api.dto.mutuelle.parts.ComptePartsSocialesResponse;
import dev.lions.unionflow.server.api.dto.mutuelle.parts.TransactionPartsSocialesRequest;
import dev.lions.unionflow.server.api.dto.mutuelle.parts.TransactionPartsSocialesResponse;
import dev.lions.unionflow.server.api.enums.mutuelle.parts.StatutComptePartsSociales;
import dev.lions.unionflow.server.api.enums.mutuelle.parts.TypeTransactionPartsSociales;
import dev.lions.unionflow.server.entity.Membre;
import dev.lions.unionflow.server.entity.Organisation;
import dev.lions.unionflow.server.entity.mutuelle.ParametresFinanciersMutuelle;
import dev.lions.unionflow.server.entity.mutuelle.parts.ComptePartsSociales;
import dev.lions.unionflow.server.entity.mutuelle.parts.TransactionPartsSociales;
import dev.lions.unionflow.server.mapper.mutuelle.parts.ComptePartsSocialesMapper;
import dev.lions.unionflow.server.mapper.mutuelle.parts.TransactionPartsSocialesMapper;
import dev.lions.unionflow.server.repository.MembreRepository;
import dev.lions.unionflow.server.repository.OrganisationRepository;
import dev.lions.unionflow.server.repository.mutuelle.ParametresFinanciersMutuellRepository;
import dev.lions.unionflow.server.repository.mutuelle.parts.ComptePartsSocialesRepository;
import dev.lions.unionflow.server.repository.mutuelle.parts.TransactionPartsSocialesRepository;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import jakarta.ws.rs.NotFoundException;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Collectors;
@ApplicationScoped
public class ComptePartsSocialesService {
private static final BigDecimal VALEUR_NOMINALE_DEFAUT = new BigDecimal("5000");
@Inject ComptePartsSocialesRepository compteRepo;
@Inject TransactionPartsSocialesRepository txRepo;
@Inject MembreRepository membreRepository;
@Inject OrganisationRepository organisationRepository;
@Inject ParametresFinanciersMutuellRepository parametresRepo;
@Inject ComptePartsSocialesMapper compteMapper;
@Inject TransactionPartsSocialesMapper txMapper;
@Transactional
public ComptePartsSocialesResponse ouvrirCompte(ComptePartsSocialesRequest req) {
Membre membre = membreRepository.findByIdOptional(UUID.fromString(req.getMembreId()))
.orElseThrow(() -> new NotFoundException("Membre introuvable: " + req.getMembreId()));
Organisation org = organisationRepository.findByIdOptional(UUID.fromString(req.getOrganisationId()))
.orElseThrow(() -> new NotFoundException("Organisation introuvable: " + req.getOrganisationId()));
// Reject duplicate (one compte per member per org)
compteRepo.findByMembreAndOrg(membre.getId(), org.getId()).ifPresent(c -> {
throw new IllegalStateException("Un compte de parts sociales existe déjà pour ce membre dans cette organisation.");
});
BigDecimal valeurNominale = resolveValeurNominale(req.getValeurNominale(), org.getId());
ComptePartsSociales compte = ComptePartsSociales.builder()
.membre(membre)
.organisation(org)
.numeroCompte(genererNumeroCompte(org))
.nombreParts(0)
.valeurNominale(valeurNominale)
.montantTotal(BigDecimal.ZERO)
.totalDividendesRecus(BigDecimal.ZERO)
.statut(StatutComptePartsSociales.ACTIF)
.dateOuverture(LocalDate.now())
.notes(req.getNotes())
.build();
compteRepo.persist(compte);
// Initial souscription si nombreParts > 0
if (req.getNombreParts() != null && req.getNombreParts() > 0) {
enregistrerTransaction(compte, TypeTransactionPartsSociales.SOUSCRIPTION,
req.getNombreParts(), null, "Souscription initiale à l'ouverture", null);
}
return compteMapper.toDto(compte);
}
@Transactional
public TransactionPartsSocialesResponse enregistrerSouscription(TransactionPartsSocialesRequest req) {
ComptePartsSociales compte = findCompteActif(req.getCompteId());
return txMapper.toDto(
enregistrerTransaction(compte, req.getTypeTransaction(),
req.getNombreParts(), req.getMontant(), req.getMotif(), req.getReferenceExterne()));
}
public ComptePartsSocialesResponse getById(UUID id) {
return compteMapper.toDto(compteRepo.findByIdOptional(id)
.orElseThrow(() -> new NotFoundException("Compte parts sociales introuvable: " + id)));
}
public List<ComptePartsSocialesResponse> getByMembre(UUID membreId) {
return compteRepo.findByMembre(membreId).stream().map(compteMapper::toDto).collect(Collectors.toList());
}
public List<ComptePartsSocialesResponse> getByOrganisation(UUID orgId) {
return compteRepo.findByOrganisation(orgId).stream().map(compteMapper::toDto).collect(Collectors.toList());
}
public List<TransactionPartsSocialesResponse> getTransactions(UUID compteId) {
return txRepo.findByCompte(compteId).stream().map(txMapper::toDto).collect(Collectors.toList());
}
// ─── Internal helpers ──────────────────────────────────────────────────────
TransactionPartsSociales enregistrerTransaction(
ComptePartsSociales compte,
TypeTransactionPartsSociales type,
int nombreParts,
BigDecimal montantOverride,
String motif,
String referenceExterne) {
if (compte.getStatut() != StatutComptePartsSociales.ACTIF) {
throw new IllegalArgumentException("Impossible d'effectuer une opération sur un compte non actif.");
}
int soldeAvant = compte.getNombreParts();
int soldeApres;
BigDecimal montant = montantOverride != null
? montantOverride
: compte.getValeurNominale().multiply(BigDecimal.valueOf(nombreParts));
switch (type) {
case SOUSCRIPTION, SOUSCRIPTION_IMPORT, PAIEMENT_DIVIDENDE -> {
soldeApres = soldeAvant + nombreParts;
compte.setNombreParts(soldeApres);
compte.setMontantTotal(compte.getValeurNominale().multiply(BigDecimal.valueOf(soldeApres)));
if (type == TypeTransactionPartsSociales.PAIEMENT_DIVIDENDE) {
compte.setTotalDividendesRecus(compte.getTotalDividendesRecus().add(montant));
}
}
case CESSION_PARTIELLE -> {
if (nombreParts > soldeAvant) {
throw new IllegalArgumentException(
"Nombre de parts à céder (" + nombreParts + ") supérieur au solde (" + soldeAvant + ").");
}
soldeApres = soldeAvant - nombreParts;
compte.setNombreParts(soldeApres);
compte.setMontantTotal(compte.getValeurNominale().multiply(BigDecimal.valueOf(soldeApres)));
}
case RACHAT_TOTAL -> {
soldeApres = 0;
compte.setNombreParts(0);
compte.setMontantTotal(BigDecimal.ZERO);
compte.setStatut(StatutComptePartsSociales.CLOS);
}
case CORRECTION -> {
// Admin correction: use nombreParts as the new absolute balance
soldeApres = nombreParts;
compte.setNombreParts(soldeApres);
compte.setMontantTotal(compte.getValeurNominale().multiply(BigDecimal.valueOf(soldeApres)));
}
default -> throw new IllegalArgumentException("Type de transaction non pris en charge: " + type);
}
compte.setDateDerniereOperation(LocalDate.now());
TransactionPartsSociales tx = TransactionPartsSociales.builder()
.compte(compte)
.typeTransaction(type)
.nombreParts(nombreParts)
.montant(montant)
.soldePartsAvant(soldeAvant)
.soldePartsApres(soldeApres)
.motif(motif)
.referenceExterne(referenceExterne)
.dateTransaction(LocalDateTime.now())
.build();
txRepo.persist(tx);
return tx;
}
private ComptePartsSociales findCompteActif(String compteId) {
return compteRepo.findByIdOptional(UUID.fromString(compteId))
.orElseThrow(() -> new NotFoundException("Compte parts sociales introuvable: " + compteId));
}
private BigDecimal resolveValeurNominale(BigDecimal fromRequest, UUID orgId) {
if (fromRequest != null && fromRequest.compareTo(BigDecimal.ZERO) > 0) {
return fromRequest;
}
return parametresRepo.findByOrganisation(orgId)
.map(ParametresFinanciersMutuelle::getValeurNominaleParDefaut)
.orElse(VALEUR_NOMINALE_DEFAUT);
}
private static final AtomicInteger COUNTER = new AtomicInteger(0);
private String genererNumeroCompte(Organisation org) {
String prefix = "PS-" + (org.getNomCourt() != null ? org.getNomCourt().toUpperCase() : "ORG") + "-";
long count = compteRepo.count("organisation.id", org.getId());
return prefix + String.format("%05d", count + 1);
}
}

View File

@@ -0,0 +1,87 @@
-- ============================================================================
-- V32 — Mutuelle : Parts Sociales + Paramètres Financiers + Intérêts
--
-- Ajoute les tables nécessaires pour les fonctionnalités manquantes identifiées
-- dans l'analyse du fichier FUSION 2013-2021.xlsx de la Mutuelle GBANE :
-- 1. comptes_parts_sociales — capital social des membres
-- 2. transactions_parts_sociales — historique des mouvements de parts
-- 3. parametres_financiers_mutuelle — taux, périodicités, valeur nominale
-- ============================================================================
-- ── 1. Paramètres financiers de la mutuelle ────────────────────────────────
CREATE TABLE IF NOT EXISTS parametres_financiers_mutuelle (
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
organisation_id UUID NOT NULL UNIQUE,
valeur_nominale_par_defaut NUMERIC(19,4) NOT NULL DEFAULT 5000,
taux_interet_annuel_epargne NUMERIC(6,4) NOT NULL DEFAULT 0.0300,
taux_dividende_parts_annuel NUMERIC(6,4) NOT NULL DEFAULT 0.0500,
periodicite_calcul VARCHAR(20) NOT NULL DEFAULT 'MENSUEL',
seuil_min_epargne_interets NUMERIC(19,4) DEFAULT 0,
prochaine_calcul_interets DATE,
dernier_calcul_interets DATE,
dernier_nb_comptes_traites INTEGER DEFAULT 0,
-- BaseEntity cols
date_creation TIMESTAMP NOT NULL DEFAULT NOW(),
date_modification TIMESTAMP,
cree_par VARCHAR(255),
modifie_par VARCHAR(255),
version BIGINT DEFAULT 0,
actif BOOLEAN NOT NULL DEFAULT TRUE,
CONSTRAINT fk_pfm_organisation FOREIGN KEY (organisation_id) REFERENCES organisations(id)
);
CREATE INDEX IF NOT EXISTS idx_pfm_org ON parametres_financiers_mutuelle(organisation_id);
-- ── 2. Comptes de parts sociales ───────────────────────────────────────────
CREATE TABLE IF NOT EXISTS comptes_parts_sociales (
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
membre_id UUID NOT NULL,
organisation_id UUID NOT NULL,
numero_compte VARCHAR(50) NOT NULL UNIQUE,
nombre_parts INTEGER NOT NULL DEFAULT 0,
valeur_nominale NUMERIC(19,4) NOT NULL,
montant_total NUMERIC(19,4) NOT NULL DEFAULT 0,
total_dividendes_recus NUMERIC(19,4) NOT NULL DEFAULT 0,
statut VARCHAR(30) NOT NULL DEFAULT 'ACTIF',
date_ouverture DATE NOT NULL DEFAULT CURRENT_DATE,
date_derniere_operation DATE,
notes VARCHAR(500),
-- BaseEntity cols
date_creation TIMESTAMP NOT NULL DEFAULT NOW(),
date_modification TIMESTAMP,
cree_par VARCHAR(255),
modifie_par VARCHAR(255),
version BIGINT DEFAULT 0,
actif BOOLEAN NOT NULL DEFAULT TRUE,
CONSTRAINT fk_cps_membre FOREIGN KEY (membre_id) REFERENCES utilisateurs(id),
CONSTRAINT fk_cps_organisation FOREIGN KEY (organisation_id) REFERENCES organisations(id)
);
CREATE INDEX IF NOT EXISTS idx_cps_numero ON comptes_parts_sociales(numero_compte);
CREATE INDEX IF NOT EXISTS idx_cps_membre ON comptes_parts_sociales(membre_id);
CREATE INDEX IF NOT EXISTS idx_cps_org ON comptes_parts_sociales(organisation_id);
-- ── 3. Transactions sur parts sociales ────────────────────────────────────
CREATE TABLE IF NOT EXISTS transactions_parts_sociales (
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
compte_id UUID NOT NULL,
type_transaction VARCHAR(50) NOT NULL,
nombre_parts INTEGER NOT NULL,
montant NUMERIC(19,4) NOT NULL,
solde_parts_avant INTEGER NOT NULL DEFAULT 0,
solde_parts_apres INTEGER NOT NULL DEFAULT 0,
motif VARCHAR(500),
reference_externe VARCHAR(100),
date_transaction TIMESTAMP NOT NULL DEFAULT NOW(),
-- BaseEntity cols
date_creation TIMESTAMP NOT NULL DEFAULT NOW(),
date_modification TIMESTAMP,
cree_par VARCHAR(255),
modifie_par VARCHAR(255),
version BIGINT DEFAULT 0,
actif BOOLEAN NOT NULL DEFAULT TRUE,
CONSTRAINT fk_tps_compte FOREIGN KEY (compte_id) REFERENCES comptes_parts_sociales(id)
);
CREATE INDEX IF NOT EXISTS idx_tps_compte ON transactions_parts_sociales(compte_id);
CREATE INDEX IF NOT EXISTS idx_tps_date ON transactions_parts_sociales(date_transaction);

View File

@@ -0,0 +1,15 @@
-- ============================================================================
-- V33 — Correction colonnes legacy de audit_logs
--
-- La V1 crée audit_logs avec action VARCHAR(50) NOT NULL (ancien schéma).
-- L'entité AuditLog utilise type_action à la place.
-- Hibernate ne remplit pas action → violation NOT NULL sur chaque insert.
-- Fix : rendre action nullable + nettoyer les autres colonnes orphelines.
-- ============================================================================
-- Rendre la colonne legacy nullable (elle est supersédée par type_action)
ALTER TABLE audit_logs ALTER COLUMN action DROP NOT NULL;
-- Aligner entite_id : la V1 déclare UUID mais l'entité stocke une String (UUID textuel)
-- → changer en VARCHAR pour éviter des cast errors sur certains IDs non-UUID
ALTER TABLE audit_logs ALTER COLUMN entite_id TYPE VARCHAR(255) USING entite_id::VARCHAR;

View File

@@ -0,0 +1,39 @@
-- ============================================================================
-- V34 — Rendre membre_id nullable dans les tables où l'entité Hibernate
-- utilise désormais une autre colonne (utilisateur_id, membre_organisation_id).
--
-- Contexte : V1 crée ces tables avec membre_id UUID NOT NULL. Les entités ont
-- évolué pour utiliser utilisateur_id (MembreOrganisation, DemandeAdhesion,
-- IntentionPaiement) ou membre_organisation_id (MembreRole). Hibernate update
-- a ajouté les nouvelles colonnes mais n'a pas supprimé membre_id.
-- Résultat : chaque insert lève une violation NOT NULL sur membre_id.
-- Fix : rendre membre_id nullable (colonne legacy, plus utilisée par le code).
-- ============================================================================
-- membres_organisations : entité utilise utilisateur_id
ALTER TABLE membres_organisations ALTER COLUMN membre_id DROP NOT NULL;
-- membres_roles : entité utilise membre_organisation_id
ALTER TABLE membres_roles ALTER COLUMN membre_id DROP NOT NULL;
-- demandes_adhesion : entité utilise utilisateur_id
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'demandes_adhesion' AND column_name = 'membre_id'
) THEN
ALTER TABLE demandes_adhesion ALTER COLUMN membre_id DROP NOT NULL;
END IF;
END $$;
-- intentions_paiement : entité utilise utilisateur_id
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'intentions_paiement' AND column_name = 'membre_id'
) THEN
ALTER TABLE intentions_paiement ALTER COLUMN membre_id DROP NOT NULL;
END IF;
END $$;

View File

@@ -0,0 +1,77 @@
-- ============================================================================
-- V35 — Recalibrage nombre_membres + trigger auto-maintien
--
-- DATA-01 : Le compteur organisations.nombre_membres est désynchronisé quand
-- des membres sont importés directement en DB (hors service Java).
-- Fix :
-- 1. Recalibrage immédiat depuis membres_organisations réels (actifs)
-- 2. Trigger PostgreSQL pour maintenir le compteur à jour automatiquement
-- ============================================================================
-- 1. Recalibrage ponctuel : recalculer depuis la table membres_organisations
UPDATE organisations o
SET nombre_membres = (
SELECT COUNT(*)
FROM membres_organisations mo
WHERE mo.organisation_id = o.id
AND mo.actif = true
AND mo.statut IN ('ACTIF', 'ACTIF_PREMIUM')
);
-- 2. Fonction trigger : incrémente/décrémente selon INSERT/UPDATE/DELETE
CREATE OR REPLACE FUNCTION update_organisation_nombre_membres()
RETURNS TRIGGER AS $$
BEGIN
IF TG_OP = 'INSERT' THEN
-- Nouveau membre actif → incrémenter
IF NEW.actif = true AND NEW.statut IN ('ACTIF', 'ACTIF_PREMIUM') THEN
UPDATE organisations
SET nombre_membres = GREATEST(0, nombre_membres + 1)
WHERE id = NEW.organisation_id;
END IF;
ELSIF TG_OP = 'UPDATE' THEN
-- Transition actif/inactif ou statut
DECLARE
was_counted BOOLEAN := OLD.actif = true AND OLD.statut IN ('ACTIF', 'ACTIF_PREMIUM');
is_counted BOOLEAN := NEW.actif = true AND NEW.statut IN ('ACTIF', 'ACTIF_PREMIUM');
BEGIN
IF NOT was_counted AND is_counted THEN
UPDATE organisations
SET nombre_membres = GREATEST(0, nombre_membres + 1)
WHERE id = NEW.organisation_id;
ELSIF was_counted AND NOT is_counted THEN
UPDATE organisations
SET nombre_membres = GREATEST(0, nombre_membres - 1)
WHERE id = OLD.organisation_id;
END IF;
END;
ELSIF TG_OP = 'DELETE' THEN
-- Suppression physique (rare)
IF OLD.actif = true AND OLD.statut IN ('ACTIF', 'ACTIF_PREMIUM') THEN
UPDATE organisations
SET nombre_membres = GREATEST(0, nombre_membres - 1)
WHERE id = OLD.organisation_id;
END IF;
END IF;
RETURN COALESCE(NEW, OLD);
END;
$$ LANGUAGE plpgsql;
-- 3. Attacher le trigger à membres_organisations
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_trigger
WHERE tgname = 'trg_update_nombre_membres'
AND tgrelid = 'membres_organisations'::regclass
) THEN
CREATE TRIGGER trg_update_nombre_membres
AFTER INSERT OR UPDATE OF actif, statut OR DELETE
ON membres_organisations
FOR EACH ROW
EXECUTE FUNCTION update_organisation_nombre_membres();
END IF;
END $$;

View File

@@ -0,0 +1,393 @@
-- ============================================================================
-- V36 — SYSCOHADA : Alignement schéma + Seeds plan comptable standard + Trigger
--
-- P0.4 ROADMAP_2026.md — Obligation OHADA SYSCOHADA révisé (applicable depuis 2018)
-- Corrige l'écart entre V1 (schéma minimal) et les entités Java (colonnes Hibernate).
-- Ajoute le plan comptable standard SYSCOHADA pour mutuelles/coopératives UEMOA.
-- ============================================================================
-- ============================================================================
-- 1. COMPTES_COMPTABLES — Alignement colonnes V1 → entité Java
-- ============================================================================
-- La V1 crée la table avec numero/libelle/type_compte/organisation_id seulement.
-- L'entité Java attend : numero_compte, classe_comptable, solde_initial, solde_actuel,
-- compte_collectif, compte_analytique, cree_par, modifie_par.
-- Renommer la colonne numero → numero_compte si elle n'a pas déjà été renommée par Hibernate
-- Sinon : si les deux colonnes coexistent (Hibernate a créé numero_compte, V1 a laissé numero),
-- on supprime l'ancienne colonne obsolète numero (NOT NULL sans défaut, bloque les INSERTs).
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'comptes_comptables' AND column_name = 'numero'
) THEN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'comptes_comptables' AND column_name = 'numero_compte'
) THEN
ALTER TABLE comptes_comptables RENAME COLUMN numero TO numero_compte;
ELSE
-- Les deux colonnes coexistent : recopier les valeurs vers numero_compte si besoin,
-- puis supprimer la colonne obsolète numero.
UPDATE comptes_comptables SET numero_compte = numero
WHERE numero_compte IS NULL AND numero IS NOT NULL;
ALTER TABLE comptes_comptables DROP COLUMN numero;
END IF;
END IF;
END $$;
-- Ajouter colonnes manquantes si pas encore créées par Hibernate update
ALTER TABLE comptes_comptables
ADD COLUMN IF NOT EXISTS classe_comptable INTEGER,
ADD COLUMN IF NOT EXISTS solde_initial DECIMAL(14,2) DEFAULT 0,
ADD COLUMN IF NOT EXISTS solde_actuel DECIMAL(14,2) DEFAULT 0,
ADD COLUMN IF NOT EXISTS compte_collectif BOOLEAN DEFAULT false,
ADD COLUMN IF NOT EXISTS compte_analytique BOOLEAN DEFAULT false,
ADD COLUMN IF NOT EXISTS description VARCHAR(500),
ADD COLUMN IF NOT EXISTS cree_par VARCHAR(255),
ADD COLUMN IF NOT EXISTS modifie_par VARCHAR(255);
-- Déduire classe_comptable depuis numero_compte si null (première chiffre du numéro)
UPDATE comptes_comptables
SET classe_comptable = CAST(LEFT(numero_compte, 1) AS INTEGER)
WHERE classe_comptable IS NULL AND numero_compte IS NOT NULL AND LENGTH(numero_compte) > 0;
-- Rendre classe_comptable NOT NULL après backfill
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'comptes_comptables' AND column_name = 'classe_comptable'
AND is_nullable = 'NO'
) THEN
ALTER TABLE comptes_comptables ALTER COLUMN classe_comptable SET NOT NULL;
END IF;
END $$;
-- Contrainte classe 1-9 (SYSCOHADA a 9 classes, pas 7)
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_compte_classe_syscohada') THEN
ALTER TABLE comptes_comptables
ADD CONSTRAINT chk_compte_classe_syscohada
CHECK (classe_comptable >= 1 AND classe_comptable <= 9);
END IF;
END $$;
-- ============================================================================
-- 2. JOURNAUX_COMPTABLES — Alignement colonnes
-- ============================================================================
ALTER TABLE journaux_comptables
ADD COLUMN IF NOT EXISTS date_debut DATE,
ADD COLUMN IF NOT EXISTS date_fin DATE,
ADD COLUMN IF NOT EXISTS statut VARCHAR(20) DEFAULT 'OUVERT',
ADD COLUMN IF NOT EXISTS description VARCHAR(500),
ADD COLUMN IF NOT EXISTS cree_par VARCHAR(255),
ADD COLUMN IF NOT EXISTS modifie_par VARCHAR(255);
-- ============================================================================
-- 3. ECRITURES_COMPTABLES — Alignement colonnes
-- ============================================================================
ALTER TABLE ecritures_comptables
ADD COLUMN IF NOT EXISTS organisation_id UUID REFERENCES organisations(id),
ADD COLUMN IF NOT EXISTS paiement_id UUID REFERENCES paiements(id),
ADD COLUMN IF NOT EXISTS reference VARCHAR(100),
ADD COLUMN IF NOT EXISTS lettrage VARCHAR(20),
ADD COLUMN IF NOT EXISTS pointe BOOLEAN DEFAULT false,
ADD COLUMN IF NOT EXISTS montant_debit DECIMAL(14,2) DEFAULT 0,
ADD COLUMN IF NOT EXISTS montant_credit DECIMAL(14,2) DEFAULT 0,
ADD COLUMN IF NOT EXISTS commentaire VARCHAR(1000),
ADD COLUMN IF NOT EXISTS cree_par VARCHAR(255),
ADD COLUMN IF NOT EXISTS modifie_par VARCHAR(255);
-- ============================================================================
-- 4. LIGNES_ECRITURE — Alignement colonnes (debit/credit → montant_debit/credit)
-- ============================================================================
-- Renommer compte_id → compte_comptable_id si besoin
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'lignes_ecriture' AND column_name = 'compte_id'
) AND NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'lignes_ecriture' AND column_name = 'compte_comptable_id'
) THEN
ALTER TABLE lignes_ecriture RENAME COLUMN compte_id TO compte_comptable_id;
END IF;
END $$;
-- Renommer debit/credit → montant_debit/montant_credit si besoin
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'lignes_ecriture' AND column_name = 'debit'
) AND NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'lignes_ecriture' AND column_name = 'montant_debit'
) THEN
ALTER TABLE lignes_ecriture RENAME COLUMN debit TO montant_debit;
END IF;
END $$;
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'lignes_ecriture' AND column_name = 'credit'
) AND NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'lignes_ecriture' AND column_name = 'montant_credit'
) THEN
ALTER TABLE lignes_ecriture RENAME COLUMN credit TO montant_credit;
END IF;
END $$;
ALTER TABLE lignes_ecriture
ADD COLUMN IF NOT EXISTS numero_ligne INTEGER,
ADD COLUMN IF NOT EXISTS reference VARCHAR(100),
ADD COLUMN IF NOT EXISTS cree_par VARCHAR(255),
ADD COLUMN IF NOT EXISTS modifie_par VARCHAR(255);
-- ============================================================================
-- 5. TABLE MODELE_PLAN_COMPTABLE — Template SYSCOHADA (comptes standards réutilisables)
-- ============================================================================
CREATE TABLE IF NOT EXISTS modele_plan_comptable (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
numero_compte VARCHAR(10) NOT NULL UNIQUE,
libelle VARCHAR(200) NOT NULL,
classe_comptable INTEGER NOT NULL CHECK (classe_comptable >= 1 AND classe_comptable <= 9),
type_compte VARCHAR(30) NOT NULL,
description VARCHAR(500),
actif BOOLEAN NOT NULL DEFAULT TRUE,
CONSTRAINT chk_modele_classe CHECK (classe_comptable >= 1 AND classe_comptable <= 9)
);
-- ============================================================================
-- 6. SEEDS — Plan comptable SYSCOHADA standard pour mutuelles/coopératives UEMOA
-- ============================================================================
INSERT INTO modele_plan_comptable (numero_compte, libelle, classe_comptable, type_compte) VALUES
-- CLASSE 1 — Ressources durables
('101000', 'Fonds propres', 1, 'PASSIF'),
('104000', 'Réserve légale', 1, 'PASSIF'),
('106000', 'Réserves statutaires', 1, 'PASSIF'),
('120000', 'Résultat de l''exercice', 1, 'PASSIF'),
('160000', 'Emprunts à long terme', 1, 'PASSIF'),
('165000', 'Dépôts et cautionnements reçus', 1, 'PASSIF'),
-- CLASSE 2 — Actif immobilisé
('222000', 'Matériel de transport', 2, 'ACTIF'),
('232000', 'Matériel informatique', 2, 'ACTIF'),
('244000', 'Logiciels informatiques', 2, 'ACTIF'),
('281000', 'Amortissements immobilisations', 2, 'ACTIF'),
-- CLASSE 4 — Tiers
('411000', 'Membres débiteurs — cotisations dues', 4, 'ACTIF'),
('412000', 'Membres débiteurs — parts sociales dues', 4, 'ACTIF'),
('413000', 'Membres débiteurs — avances sur prestations', 4, 'ACTIF'),
('421000', 'Personnel — rémunérations dues', 4, 'PASSIF'),
('431000', 'Sécurité sociale — cotisations patronales', 4, 'PASSIF'),
('441000', 'État — TVA collectée', 4, 'PASSIF'),
('447000', 'État — autres impôts et taxes', 4, 'PASSIF'),
('467000', 'Tiers divers débiteurs', 4, 'ACTIF'),
('468000', 'Tiers divers créditeurs', 4, 'PASSIF'),
-- CLASSE 5 — Trésorerie
('512100', 'Compte Wave Senegal', 5, 'TRESORERIE'),
('512200', 'Compte Orange Money', 5, 'TRESORERIE'),
('512300', 'Compte MTN MoMo', 5, 'TRESORERIE'),
('512400', 'Compte Moov Money', 5, 'TRESORERIE'),
('512500', 'Compte bancaire principal', 5, 'TRESORERIE'),
('531000', 'Caisse principale', 5, 'TRESORERIE'),
('581000', 'Virements internes de trésorerie', 5, 'TRESORERIE'),
-- CLASSE 6 — Charges
('601000', 'Achats de marchandises', 6, 'CHARGES'),
('611000', 'Transports', 6, 'CHARGES'),
('612000', 'Frais de télécommunications', 6, 'CHARGES'),
('613000', 'Frais d''assurance', 6, 'CHARGES'),
('614000', 'Location matériel', 6, 'CHARGES'),
('616000', 'Frais d''entretien et réparations', 6, 'CHARGES'),
('621000', 'Personnel externe (prestataires)', 6, 'CHARGES'),
('622000', 'Rémunérations du personnel', 6, 'CHARGES'),
('631000', 'Frais financiers — intérêts d''emprunts', 6, 'CHARGES'),
('641000', 'Charges sur prestations mutuelles', 6, 'CHARGES'),
('651000', 'Pertes sur créances irrécouvrables', 6, 'CHARGES'),
-- CLASSE 7 — Produits
('706100', 'Cotisations ordinaires membres', 7, 'PRODUITS'),
('706200', 'Cotisations spéciales / majorées', 7, 'PRODUITS'),
('706300', 'Parts sociales', 7, 'PRODUITS'),
('706400', 'Droits d''adhésion', 7, 'PRODUITS'),
('762000', 'Produits financiers — intérêts épargne', 7, 'PRODUITS'),
('771000', 'Subventions d''exploitation reçues', 7, 'PRODUITS'),
('775000', 'Prestations de services', 7, 'PRODUITS'),
-- CLASSE 8 — Charges et produits exceptionnels / hors activité
('870000', 'Dons reçus', 8, 'PRODUITS'),
('871000', 'Legs et donations', 8, 'PRODUITS'),
('875000', 'Produits exceptionnels d''événements', 8, 'PRODUITS'),
('878000', 'Autres produits hors activité ordinaire', 8, 'PRODUITS'),
('880000', 'Charges exceptionnelles', 8, 'CHARGES'),
-- CLASSE 9 — Engagements / comptabilité analytique
('990000', 'Engagements hors bilan donnés', 9, 'AUTRE'),
('991000', 'Engagements hors bilan reçus', 9, 'AUTRE')
ON CONFLICT (numero_compte) DO NOTHING;
-- ============================================================================
-- 7. TRIGGER — Initialisation automatique du plan comptable à la création d'org
-- ============================================================================
CREATE OR REPLACE FUNCTION init_plan_comptable_organisation()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO comptes_comptables (
id, numero_compte, libelle, classe_comptable, type_compte,
description, organisation_id, solde_initial, solde_actuel,
compte_collectif, compte_analytique, actif,
date_creation, version
)
SELECT
gen_random_uuid(),
m.numero_compte,
m.libelle,
m.classe_comptable,
m.type_compte,
m.description,
NEW.id,
0, 0,
false, false, true,
NOW(), 0
FROM modele_plan_comptable m
WHERE m.actif = true;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_trigger
WHERE tgname = 'trg_init_plan_comptable_org'
AND tgrelid = 'organisations'::regclass
) THEN
CREATE TRIGGER trg_init_plan_comptable_org
AFTER INSERT ON organisations
FOR EACH ROW
EXECUTE FUNCTION init_plan_comptable_organisation();
END IF;
END $$;
-- ============================================================================
-- 8. BACKFILL — Initialiser le plan comptable pour les organisations existantes
-- (qui ont été créées avant ce trigger)
-- ============================================================================
INSERT INTO comptes_comptables (
id, numero_compte, libelle, classe_comptable, type_compte,
description, organisation_id, solde_initial, solde_actuel,
compte_collectif, compte_analytique, actif,
date_creation, version
)
SELECT
gen_random_uuid(),
m.numero_compte,
m.libelle,
m.classe_comptable,
m.type_compte,
m.description,
o.id,
0, 0,
false, false, true,
NOW(), 0
FROM organisations o
CROSS JOIN modele_plan_comptable m
WHERE m.actif = true
AND NOT EXISTS (
SELECT 1 FROM comptes_comptables cc
WHERE cc.organisation_id = o.id
AND cc.numero_compte = m.numero_compte
);
-- ============================================================================
-- 9. JOURNAUX STANDARD par organisation
-- ============================================================================
-- Remplacer la contrainte UNIQUE globale sur `code` par une contrainte composite
-- (organisation_id, code) — plusieurs orgs peuvent avoir un journal ACH/VTE/etc.
DO $$
DECLARE
constraint_name text;
BEGIN
SELECT tc.constraint_name INTO constraint_name
FROM information_schema.table_constraints tc
JOIN information_schema.constraint_column_usage ccu
ON tc.constraint_name = ccu.constraint_name
WHERE tc.table_name = 'journaux_comptables'
AND tc.constraint_type = 'UNIQUE'
AND ccu.column_name = 'code'
AND NOT EXISTS (
SELECT 1 FROM information_schema.constraint_column_usage ccu2
WHERE ccu2.constraint_name = tc.constraint_name
AND ccu2.column_name = 'organisation_id'
);
IF constraint_name IS NOT NULL THEN
EXECUTE 'ALTER TABLE journaux_comptables DROP CONSTRAINT ' || quote_ident(constraint_name);
END IF;
END $$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'uk_journaux_org_code'
) THEN
ALTER TABLE journaux_comptables
ADD CONSTRAINT uk_journaux_org_code UNIQUE (organisation_id, code);
END IF;
END $$;
INSERT INTO journaux_comptables (
id, code, libelle, type_journal, organisation_id,
statut, actif, date_creation, version
)
SELECT
gen_random_uuid(),
jtype.code,
jtype.libelle,
jtype.type_journal,
o.id,
'OUVERT', true, NOW(), 0
FROM organisations o
CROSS JOIN (VALUES
('ACH', 'Journal des achats', 'ACHATS'),
('VTE', 'Journal des ventes / cotisations', 'VENTES'),
('BQ', 'Journal bancaire', 'BANQUE'),
('CAI', 'Journal de caisse', 'CAISSE'),
('OD', 'Journal des opérations diverses', 'OD')
) AS jtype(code, libelle, type_journal)
WHERE NOT EXISTS (
SELECT 1 FROM journaux_comptables jc
WHERE jc.organisation_id = o.id
AND jc.type_journal = jtype.type_journal
);
-- ============================================================================
-- 10. INDEX utiles
-- ============================================================================
CREATE INDEX IF NOT EXISTS idx_comptes_org_numero
ON comptes_comptables (organisation_id, numero_compte);
CREATE INDEX IF NOT EXISTS idx_comptes_org_classe
ON comptes_comptables (organisation_id, classe_comptable);
CREATE INDEX IF NOT EXISTS idx_ecritures_org_date
ON ecritures_comptables (organisation_id, date_ecriture);
CREATE INDEX IF NOT EXISTS idx_lignes_compte
ON lignes_ecriture (compte_comptable_id);

View File

@@ -0,0 +1,14 @@
-- ============================================================================
-- V37 — Keycloak 26 Organizations : ajout keycloak_org_id sur organisations
--
-- P0.2 ROADMAP_2026.md — Migration Keycloak 23 → 26 + Organizations natives
-- Stocke l'ID Keycloak Organization correspondant à chaque organisation UnionFlow.
-- Null = organisation pas encore migrée vers Keycloak 26 Organizations.
-- ============================================================================
ALTER TABLE organisations
ADD COLUMN IF NOT EXISTS keycloak_org_id UUID;
CREATE INDEX IF NOT EXISTS idx_organisations_keycloak_org_id
ON organisations (keycloak_org_id)
WHERE keycloak_org_id IS NOT NULL;

View File

@@ -0,0 +1,64 @@
-- ============================================================================
-- V38 — Module KYC/AML : table kyc_dossier
--
-- P1.5 ROADMAP_2026.md — KYC/AML — conformité GIABA/BCEAO LCB-FT
-- Rétention 10 ans (GIABA) gérée par colonne annee_reference + archivage planifié.
-- ============================================================================
CREATE TABLE IF NOT EXISTS kyc_dossier (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Identité du membre
membre_id UUID NOT NULL REFERENCES utilisateurs(id),
-- Pièce d'identité
type_piece VARCHAR(30) NOT NULL,
numero_piece VARCHAR(50) NOT NULL,
date_expiration_piece DATE,
-- Fichiers stockés (MinIO/S3 — identifiants opaques)
piece_identite_recto_file_id VARCHAR(500),
piece_identite_verso_file_id VARCHAR(500),
justif_domicile_file_id VARCHAR(500),
-- Évaluation risque LCB-FT
statut VARCHAR(20) NOT NULL DEFAULT 'NON_VERIFIE',
niveau_risque VARCHAR(20) NOT NULL DEFAULT 'FAIBLE',
score_risque INTEGER NOT NULL DEFAULT 0
CHECK (score_risque >= 0 AND score_risque <= 100),
-- PEP (Personne Exposée Politiquement)
est_pep BOOLEAN NOT NULL DEFAULT FALSE,
nationalite VARCHAR(5),
-- Validation
date_verification TIMESTAMP,
validateur_id UUID REFERENCES utilisateurs(id),
notes_validateur VARCHAR(1000),
-- Rétention 10 ans GIABA — partitionnement logique par année
annee_reference INTEGER NOT NULL DEFAULT EXTRACT(YEAR FROM NOW()),
-- BaseEntity
date_creation TIMESTAMP NOT NULL DEFAULT NOW(),
date_modification TIMESTAMP,
cree_par VARCHAR(255),
modifie_par VARCHAR(255),
version BIGINT NOT NULL DEFAULT 0,
actif BOOLEAN NOT NULL DEFAULT TRUE,
CONSTRAINT chk_kyc_annee_reference CHECK (annee_reference >= 2020 AND annee_reference <= 2100)
);
-- Un seul dossier actif par membre (le plus récent est actif, les anciens archivés)
CREATE UNIQUE INDEX IF NOT EXISTS idx_kyc_membre_actif
ON kyc_dossier (membre_id)
WHERE actif = TRUE;
CREATE INDEX IF NOT EXISTS idx_kyc_membre_id ON kyc_dossier (membre_id);
CREATE INDEX IF NOT EXISTS idx_kyc_statut ON kyc_dossier (statut);
CREATE INDEX IF NOT EXISTS idx_kyc_niveau_risque ON kyc_dossier (niveau_risque);
CREATE INDEX IF NOT EXISTS idx_kyc_est_pep ON kyc_dossier (est_pep) WHERE est_pep = TRUE;
CREATE INDEX IF NOT EXISTS idx_kyc_annee ON kyc_dossier (annee_reference);
CREATE INDEX IF NOT EXISTS idx_kyc_date_expiration ON kyc_dossier (date_expiration_piece)
WHERE date_expiration_piece IS NOT NULL;

View File

@@ -0,0 +1,174 @@
-- ============================================================================
-- V39 — PostgreSQL Row-Level Security : isolation multi-tenant
--
-- P1.2 ROADMAP_2026.md — Multi-tenancy RLS sur tables tenant-scoped
--
-- Variables de session :
-- app.current_org_id : UUID de l'organisation active (set par RlsConnectionInitializer)
-- app.is_super_admin : 'true' si SUPER_ADMIN (bypass RLS pour dashboards globaux)
--
-- Notes sécurité :
-- - Ne pas activer FORCE ROW LEVEL SECURITY ici — le user Flyway (owner) bypasse naturellement.
-- - En prod : créer user `unionflow_app` sans BYPASSRLS pour le pool Quarkus.
-- - Le user Flyway (`unionflow_admin` ou `postgres`) doit avoir BYPASSRLS ou être owner.
-- ============================================================================
-- ============================================================================
-- Helper : policy template pour tables avec organisation_id direct
-- ============================================================================
-- TABLE cotisations
ALTER TABLE cotisations ENABLE ROW LEVEL SECURITY;
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_policy WHERE polname = 'rls_tenant_cotisations') THEN
CREATE POLICY rls_tenant_cotisations ON cotisations
USING (
organisation_id = current_setting('app.current_org_id', true)::uuid
OR current_setting('app.is_super_admin', true) = 'true'
);
END IF;
END $$;
-- TABLE souscriptions_organisation
ALTER TABLE souscriptions_organisation ENABLE ROW LEVEL SECURITY;
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_policy WHERE polname = 'rls_tenant_souscriptions') THEN
CREATE POLICY rls_tenant_souscriptions ON souscriptions_organisation
USING (
organisation_id = current_setting('app.current_org_id', true)::uuid
OR current_setting('app.is_super_admin', true) = 'true'
);
END IF;
END $$;
-- TABLE evenements
ALTER TABLE evenements ENABLE ROW LEVEL SECURITY;
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_policy WHERE polname = 'rls_tenant_evenements') THEN
CREATE POLICY rls_tenant_evenements ON evenements
USING (
organisation_id = current_setting('app.current_org_id', true)::uuid
OR current_setting('app.is_super_admin', true) = 'true'
);
END IF;
END $$;
-- TABLE documents
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_policy WHERE polname = 'rls_tenant_documents') THEN
CREATE POLICY rls_tenant_documents ON documents
USING (
organisation_id = current_setting('app.current_org_id', true)::uuid
OR current_setting('app.is_super_admin', true) = 'true'
);
END IF;
END $$;
-- TABLE comptes_comptables
ALTER TABLE comptes_comptables ENABLE ROW LEVEL SECURITY;
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_policy WHERE polname = 'rls_tenant_comptes_comptables') THEN
CREATE POLICY rls_tenant_comptes_comptables ON comptes_comptables
USING (
organisation_id = current_setting('app.current_org_id', true)::uuid
OR current_setting('app.is_super_admin', true) = 'true'
);
END IF;
END $$;
-- TABLE journaux_comptables
ALTER TABLE journaux_comptables ENABLE ROW LEVEL SECURITY;
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_policy WHERE polname = 'rls_tenant_journaux_comptables') THEN
CREATE POLICY rls_tenant_journaux_comptables ON journaux_comptables
USING (
organisation_id = current_setting('app.current_org_id', true)::uuid
OR current_setting('app.is_super_admin', true) = 'true'
);
END IF;
END $$;
-- TABLE ecritures_comptables
ALTER TABLE ecritures_comptables ENABLE ROW LEVEL SECURITY;
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_policy WHERE polname = 'rls_tenant_ecritures_comptables') THEN
CREATE POLICY rls_tenant_ecritures_comptables ON ecritures_comptables
USING (
organisation_id = current_setting('app.current_org_id', true)::uuid
OR current_setting('app.is_super_admin', true) = 'true'
);
END IF;
END $$;
-- TABLE kyc_dossier (scoped via membres_organisations JOIN)
-- Note : kyc_dossier n'a pas d'organisation_id direct — scope via membre_id + membres_organisations
ALTER TABLE kyc_dossier ENABLE ROW LEVEL SECURITY;
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_policy WHERE polname = 'rls_tenant_kyc_dossier') THEN
CREATE POLICY rls_tenant_kyc_dossier ON kyc_dossier
USING (
EXISTS (
SELECT 1 FROM membres_organisations mo
WHERE mo.utilisateur_id = kyc_dossier.membre_id
AND mo.organisation_id = current_setting('app.current_org_id', true)::uuid
AND mo.actif = true
)
OR current_setting('app.is_super_admin', true) = 'true'
);
END IF;
END $$;
-- TABLE membres_organisations (scope par organisation)
ALTER TABLE membres_organisations ENABLE ROW LEVEL SECURITY;
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_policy WHERE polname = 'rls_tenant_membres_organisations') THEN
CREATE POLICY rls_tenant_membres_organisations ON membres_organisations
USING (
organisation_id = current_setting('app.current_org_id', true)::uuid
OR current_setting('app.is_super_admin', true) = 'true'
);
END IF;
END $$;
-- TABLE budgets
ALTER TABLE budgets ENABLE ROW LEVEL SECURITY;
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_policy WHERE polname = 'rls_tenant_budgets') THEN
CREATE POLICY rls_tenant_budgets ON budgets
USING (
organisation_id = current_setting('app.current_org_id', true)::uuid
OR current_setting('app.is_super_admin', true) = 'true'
);
END IF;
END $$;
-- TABLE tontines (si applicable)
DO $$ BEGIN
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'tontines')
AND NOT EXISTS (SELECT 1 FROM pg_policy WHERE polname = 'rls_tenant_tontines') THEN
EXECUTE 'ALTER TABLE tontines ENABLE ROW LEVEL SECURITY';
EXECUTE '
CREATE POLICY rls_tenant_tontines ON tontines
USING (
organisation_id = current_setting(''app.current_org_id'', true)::uuid
OR current_setting(''app.is_super_admin'', true) = ''true''
)';
END IF;
END $$;
-- ============================================================================
-- Rôle PostgreSQL applicatif (prod only — commenté pour ne pas casser dev)
-- À exécuter manuellement en prod avec le bon mot de passe.
-- ============================================================================
-- CREATE ROLE unionflow_app LOGIN PASSWORD '<UNIONFLOW_APP_DB_PASSWORD>';
-- GRANT CONNECT ON DATABASE unionflow TO unionflow_app;
-- GRANT USAGE ON SCHEMA public TO unionflow_app;
-- GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO unionflow_app;
-- GRANT USAGE ON ALL SEQUENCES IN SCHEMA public TO unionflow_app;
-- -- unionflow_app N'A PAS BYPASSRLS — RLS s'applique toujours
--
-- CREATE ROLE unionflow_admin LOGIN PASSWORD '<UNIONFLOW_ADMIN_DB_PASSWORD>' BYPASSRLS;
-- GRANT ALL ON ALL TABLES IN SCHEMA public TO unionflow_admin;
-- GRANT ALL ON ALL SEQUENCES IN SCHEMA public TO unionflow_admin;
-- -- unionflow_admin utilisé par Flyway et SuperAdminCrossTenantService

View File

@@ -0,0 +1,9 @@
-- V40: Ajout du provider de paiement par défaut sur FormuleAbonnement
-- Permet de configurer le provider (WAVE, ORANGE_MONEY, MTN_MOMO, PISPI) par formule
-- NULL = utiliser le provider global configuré dans application.properties
ALTER TABLE formules_abonnement
ADD COLUMN IF NOT EXISTS provider_defaut VARCHAR(20);
COMMENT ON COLUMN formules_abonnement.provider_defaut IS
'Code du provider de paiement par défaut pour cette formule (WAVE, ORANGE_MONEY, MTN_MOMO, PISPI). NULL = provider global.';

View File

@@ -0,0 +1,12 @@
-- V41: Token FCM (Firebase Cloud Messaging) pour les notifications push mobile
-- Nullable : vide si le membre n'a pas installé l'app mobile ou refusé les notifications
-- Table : utilisateurs (entité Membre.java → @Table(name = "utilisateurs"))
ALTER TABLE utilisateurs
ADD COLUMN IF NOT EXISTS fcm_token VARCHAR(500);
COMMENT ON COLUMN utilisateurs.fcm_token IS
'Token FCM pour les notifications push Firebase. NULL si non enregistré.';
CREATE INDEX IF NOT EXISTS idx_utilisateurs_fcm_token
ON utilisateurs (fcm_token) WHERE fcm_token IS NOT NULL;

View File

@@ -0,0 +1,41 @@
-- V42: Créer les rôles PostgreSQL pour l'isolation RLS
-- unionflow_app : rôle applicatif (sans BYPASSRLS) — utilisé en prod par le backend
-- unionflow_admin: rôle administrateur (BYPASSRLS) — utilisé pour les migrations Flyway et les ops DBA
DO $$
BEGIN
-- Rôle applicatif (sans bypass RLS — soumis aux policies)
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'unionflow_app') THEN
CREATE ROLE unionflow_app LOGIN PASSWORD 'CHANGE_ME_APP_PASSWORD';
END IF;
-- Rôle administrateur (bypass RLS — pour Flyway, exports, audits DBA)
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'unionflow_admin') THEN
CREATE ROLE unionflow_admin LOGIN PASSWORD 'CHANGE_ME_ADMIN_PASSWORD' BYPASSRLS;
END IF;
END
$$;
-- Accorder les privilèges sur le schéma public
GRANT USAGE ON SCHEMA public TO unionflow_app, unionflow_admin;
-- unionflow_app : DML uniquement (pas DDL)
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO unionflow_app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO unionflow_app;
-- unionflow_admin : tous les droits (DDL inclus pour Flyway)
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO unionflow_admin;
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO unionflow_admin;
-- Garantir les droits sur les objets créés ultérieurement (nouvelles tables Flyway)
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO unionflow_app;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT USAGE, SELECT ON SEQUENCES TO unionflow_app;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT ALL PRIVILEGES ON TABLES TO unionflow_admin;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT ALL PRIVILEGES ON SEQUENCES TO unionflow_admin;
COMMENT ON ROLE unionflow_app IS 'Rôle applicatif UnionFlow — soumis aux policies RLS tenant isolation';
COMMENT ON ROLE unionflow_admin IS 'Rôle DBA UnionFlow — BYPASSRLS pour Flyway et exports';

View File

@@ -0,0 +1,42 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>Bienvenue sur UnionFlow</title>
<style>
body { font-family: Arial, sans-serif; background: #f4f6f9; margin: 0; padding: 0; }
.container { max-width: 600px; margin: 30px auto; background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,.1); }
.header { background: #1A568C; color: #fff; padding: 28px 32px; }
.header h1 { margin: 0; font-size: 22px; }
.body { padding: 28px 32px; color: #333; line-height: 1.6; }
.btn { display: inline-block; margin-top: 20px; padding: 12px 28px; background: #1A568C; color: #fff; text-decoration: none; border-radius: 5px; font-weight: bold; }
.footer { background: #f4f6f9; text-align: center; padding: 16px; font-size: 12px; color: #999; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🎉 Bienvenue sur UnionFlow !</h1>
</div>
<div class="body">
<p>Bonjour <strong>{prenom} {nom}</strong>,</p>
<p>Votre compte a été créé avec succès sur <strong>UnionFlow</strong>, la plateforme de gestion des mutuelles, coopératives et syndicats de Côte d'Ivoire.</p>
<p>Vous faites maintenant partie de l'organisation : <strong>{nomOrganisation}</strong></p>
<p>Votre identifiant de connexion est votre adresse email : <strong>{email}</strong></p>
{#if lienConnexion}
<p>
<a href="{lienConnexion}" class="btn">Accéder à mon espace</a>
</p>
{/if}
<p>En cas de question, contactez votre administrateur ou notre support : <a href="mailto:support@lions.dev">support@lions.dev</a></p>
<p>Cordialement,<br>L'équipe UnionFlow</p>
</div>
<div class="footer">UnionFlow © 2026 — Lions Tech SARL — Abidjan, Côte d'Ivoire</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,47 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>Confirmation de cotisation</title>
<style>
body { font-family: Arial, sans-serif; background: #f4f6f9; margin: 0; padding: 0; }
.container { max-width: 600px; margin: 30px auto; background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,.1); }
.header { background: #1A568C; color: #fff; padding: 28px 32px; }
.header h1 { margin: 0; font-size: 22px; }
.body { padding: 28px 32px; color: #333; line-height: 1.6; }
.receipt { background: #f8faff; border: 1px solid #dce8f8; border-radius: 6px; padding: 18px; margin: 18px 0; }
.receipt table { width: 100%; border-collapse: collapse; }
.receipt td { padding: 7px 0; }
.receipt td:last-child { text-align: right; font-weight: bold; }
.amount { font-size: 24px; font-weight: bold; color: #1A568C; }
.badge-success { display: inline-block; background: #e6f4ea; color: #2e7d32; padding: 4px 12px; border-radius: 20px; font-size: 13px; font-weight: bold; }
.footer { background: #f4f6f9; text-align: center; padding: 16px; font-size: 12px; color: #999; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>✅ Cotisation confirmée</h1>
</div>
<div class="body">
<p>Bonjour <strong>{prenom} {nom}</strong>,</p>
<p>Nous avons bien reçu votre cotisation. <span class="badge-success">CONFIRMÉ</span></p>
<div class="receipt">
<table>
<tr><td>Organisation</td><td>{nomOrganisation}</td></tr>
<tr><td>Période</td><td>{periode}</td></tr>
<tr><td>Référence</td><td>{numeroReference}</td></tr>
<tr><td>Mode de paiement</td><td>{methodePaiement}</td></tr>
<tr><td>Date de paiement</td><td>{datePaiement}</td></tr>
<tr><td>Montant</td><td><span class="amount">{montant} XOF</span></td></tr>
</table>
</div>
<p>Conservez cet email comme justificatif de paiement.</p>
<p>Cordialement,<br>L'équipe UnionFlow</p>
</div>
<div class="footer">UnionFlow © 2026 — Lions Tech SARL — Abidjan, Côte d'Ivoire</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,45 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>Rappel de cotisation</title>
<style>
body { font-family: Arial, sans-serif; background: #f4f6f9; margin: 0; padding: 0; }
.container { max-width: 600px; margin: 30px auto; background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,.1); }
.header { background: #e65100; color: #fff; padding: 28px 32px; }
.header h1 { margin: 0; font-size: 22px; }
.body { padding: 28px 32px; color: #333; line-height: 1.6; }
.alert { background: #fff3e0; border-left: 4px solid #e65100; padding: 14px 18px; border-radius: 4px; margin: 18px 0; }
.btn { display: inline-block; margin-top: 16px; padding: 12px 28px; background: #e65100; color: #fff; text-decoration: none; border-radius: 5px; font-weight: bold; }
.footer { background: #f4f6f9; text-align: center; padding: 16px; font-size: 12px; color: #999; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>⚠️ Rappel de cotisation</h1>
</div>
<div class="body">
<p>Bonjour <strong>{prenom} {nom}</strong>,</p>
<div class="alert">
<strong>Votre cotisation pour la période {periode} est en attente de paiement.</strong>
</div>
<p>Organisation : <strong>{nomOrganisation}</strong></p>
<p>Montant dû : <strong>{montant} XOF</strong></p>
<p>Date limite : <strong>{dateLimite}</strong></p>
{#if lienPaiement}
<p>
<a href="{lienPaiement}" class="btn">Payer ma cotisation</a>
</p>
{/if}
<p>Si vous avez déjà effectué ce paiement, veuillez ignorer ce message ou contacter votre trésorier.</p>
<p>Cordialement,<br>L'équipe UnionFlow</p>
</div>
<div class="footer">UnionFlow © 2026 — Lions Tech SARL — Abidjan, Côte d'Ivoire</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,50 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>Souscription confirmée</title>
<style>
body { font-family: Arial, sans-serif; background: #f4f6f9; margin: 0; padding: 0; }
.container { max-width: 600px; margin: 30px auto; background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,.1); }
.header { background: #1A568C; color: #fff; padding: 28px 32px; }
.header h1 { margin: 0; font-size: 22px; }
.body { padding: 28px 32px; color: #333; line-height: 1.6; }
.plan-card { background: #e8f0fe; border-radius: 8px; padding: 20px; margin: 18px 0; text-align: center; }
.plan-name { font-size: 20px; font-weight: bold; color: #1A568C; }
.plan-price { font-size: 28px; font-weight: bold; color: #1A568C; margin: 8px 0; }
.features { margin: 16px 0; }
.features li { padding: 4px 0; }
.badge { display: inline-block; background: #e6f4ea; color: #2e7d32; padding: 4px 12px; border-radius: 20px; font-size: 13px; font-weight: bold; }
.footer { background: #f4f6f9; text-align: center; padding: 16px; font-size: 12px; color: #999; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>✅ Souscription activée</h1>
</div>
<div class="body">
<p>Bonjour <strong>{nomAdministrateur}</strong>,</p>
<p>La souscription de votre organisation <strong>{nomOrganisation}</strong> a été activée avec succès. <span class="badge">ACTIF</span></p>
<div class="plan-card">
<div class="plan-name">Plan {nomFormule}</div>
<div class="plan-price">{montant} XOF / {periodicite}</div>
</div>
<p><strong>Détails de la souscription :</strong></p>
<ul class="features">
<li>Date d'activation : {dateActivation}</li>
<li>Date d'expiration : {dateExpiration}</li>
<li>Membres maximum : {maxMembres}</li>
<li>Stockage : {maxStockageMo} Mo</li>
{#if apiAccess}<li>✓ Accès API REST</li>{/if}
{#if supportPrioritaire}<li>✓ Support prioritaire</li>{/if}
</ul>
<p>Cordialement,<br>L'équipe UnionFlow</p>
</div>
<div class="footer">UnionFlow © 2026 — Lions Tech SARL — Abidjan, Côte d'Ivoire</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,79 @@
package dev.lions.unionflow.server.client;
import io.quarkus.oidc.client.OidcClient;
import io.quarkus.oidc.client.Tokens;
import io.smallrye.mutiny.Uni;
import jakarta.ws.rs.ServiceUnavailableException;
import jakarta.ws.rs.core.MultivaluedHashMap;
import jakarta.ws.rs.core.MultivaluedMap;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.lang.reflect.Field;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class AdminServiceTokenHeadersFactoryTest {
@Mock
OidcClient adminOidcClient;
private AdminServiceTokenHeadersFactory factory;
@BeforeEach
void setUp() throws Exception {
factory = new AdminServiceTokenHeadersFactory();
Field clientField = AdminServiceTokenHeadersFactory.class.getDeclaredField("adminOidcClient");
clientField.setAccessible(true);
clientField.set(factory, adminOidcClient);
}
@Test
void update_whenTokensObtained_addsAuthorizationHeader() {
Tokens tokens = mock(Tokens.class);
when(tokens.getAccessToken()).thenReturn("service-account-token-xyz");
when(adminOidcClient.getTokens()).thenReturn(Uni.createFrom().item(tokens));
MultivaluedMap<String, String> incoming = new MultivaluedHashMap<>();
MultivaluedMap<String, String> outgoing = new MultivaluedHashMap<>();
MultivaluedMap<String, String> result = factory.update(incoming, outgoing);
assertThat(result.getFirst("Authorization")).isEqualTo("Bearer service-account-token-xyz");
}
@Test
void update_whenOidcClientFails_throwsServiceUnavailableException() {
when(adminOidcClient.getTokens()).thenReturn(
Uni.createFrom().failure(new RuntimeException("Keycloak unreachable")));
MultivaluedMap<String, String> incoming = new MultivaluedHashMap<>();
MultivaluedMap<String, String> outgoing = new MultivaluedHashMap<>();
assertThatThrownBy(() -> factory.update(incoming, outgoing))
.isInstanceOf(ServiceUnavailableException.class)
.hasMessageContaining("authentification");
}
@Test
void update_whenOidcClientReturnsNullToken_stillAddsHeader() {
Tokens tokens = mock(Tokens.class);
when(tokens.getAccessToken()).thenReturn("");
when(adminOidcClient.getTokens()).thenReturn(Uni.createFrom().item(tokens));
MultivaluedMap<String, String> incoming = new MultivaluedHashMap<>();
MultivaluedMap<String, String> outgoing = new MultivaluedHashMap<>();
MultivaluedMap<String, String> result = factory.update(incoming, outgoing);
assertThat(result.getFirst("Authorization")).isEqualTo("Bearer ");
}
}

View File

@@ -0,0 +1,43 @@
package dev.lions.unionflow.server.common;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class ErrorResponseTest {
@Test
void constructorAndAccessors() {
ErrorResponse response = new ErrorResponse("some message", "some error");
assertThat(response.message()).isEqualTo("some message");
assertThat(response.error()).isEqualTo("some error");
}
@Test
void of_setsMessageNullError() {
ErrorResponse response = ErrorResponse.of("something went wrong");
assertThat(response.message()).isEqualTo("something went wrong");
assertThat(response.error()).isNull();
}
@Test
void ofError_setsErrorNullMessage() {
ErrorResponse response = ErrorResponse.ofError("NOT_FOUND");
assertThat(response.error()).isEqualTo("NOT_FOUND");
assertThat(response.message()).isNull();
}
@Test
void record_equality() {
ErrorResponse r1 = new ErrorResponse("msg", "err");
ErrorResponse r2 = new ErrorResponse("msg", "err");
assertThat(r1).isEqualTo(r2);
assertThat(r1.hashCode()).isEqualTo(r2.hashCode());
}
@Test
void record_toString_containsFields() {
ErrorResponse response = new ErrorResponse("hello", "world");
assertThat(response.toString()).contains("hello").contains("world");
}
}

View File

@@ -0,0 +1,250 @@
package dev.lions.unionflow.server.entity;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("AlertConfiguration")
class AlertConfigurationTest {
// -------------------------------------------------------------------------
// helpers
// -------------------------------------------------------------------------
private AlertConfiguration newConfig() {
return new AlertConfiguration();
}
// -------------------------------------------------------------------------
// default values
// -------------------------------------------------------------------------
@Test
@DisplayName("default field values are applied by field initializers")
void defaultValues() {
AlertConfiguration c = newConfig();
assertThat(c.getCpuHighAlertEnabled()).isTrue();
assertThat(c.getCpuThresholdPercent()).isEqualTo(80);
assertThat(c.getCpuDurationMinutes()).isEqualTo(5);
assertThat(c.getMemoryLowAlertEnabled()).isTrue();
assertThat(c.getMemoryThresholdPercent()).isEqualTo(85);
assertThat(c.getCriticalErrorAlertEnabled()).isTrue();
assertThat(c.getErrorAlertEnabled()).isTrue();
assertThat(c.getConnectionFailureAlertEnabled()).isTrue();
assertThat(c.getConnectionFailureThreshold()).isEqualTo(100);
assertThat(c.getConnectionFailureWindowMinutes()).isEqualTo(5);
assertThat(c.getEmailNotificationsEnabled()).isTrue();
assertThat(c.getPushNotificationsEnabled()).isFalse();
assertThat(c.getSmsNotificationsEnabled()).isFalse();
assertThat(c.getAlertEmailRecipients()).isEqualTo("admin@unionflow.test");
}
// -------------------------------------------------------------------------
// getters / setters — CPU
// -------------------------------------------------------------------------
@Test
@DisplayName("setCpuHighAlertEnabled / getCpuHighAlertEnabled")
void cpuHighAlertEnabled() {
AlertConfiguration c = newConfig();
c.setCpuHighAlertEnabled(false);
assertThat(c.getCpuHighAlertEnabled()).isFalse();
c.setCpuHighAlertEnabled(true);
assertThat(c.getCpuHighAlertEnabled()).isTrue();
}
@Test
@DisplayName("setCpuThresholdPercent / getCpuThresholdPercent")
void cpuThresholdPercent() {
AlertConfiguration c = newConfig();
c.setCpuThresholdPercent(95);
assertThat(c.getCpuThresholdPercent()).isEqualTo(95);
}
@Test
@DisplayName("setCpuDurationMinutes / getCpuDurationMinutes")
void cpuDurationMinutes() {
AlertConfiguration c = newConfig();
c.setCpuDurationMinutes(10);
assertThat(c.getCpuDurationMinutes()).isEqualTo(10);
}
// -------------------------------------------------------------------------
// getters / setters — Memory
// -------------------------------------------------------------------------
@Test
@DisplayName("setMemoryLowAlertEnabled / getMemoryLowAlertEnabled")
void memoryLowAlertEnabled() {
AlertConfiguration c = newConfig();
c.setMemoryLowAlertEnabled(false);
assertThat(c.getMemoryLowAlertEnabled()).isFalse();
}
@Test
@DisplayName("setMemoryThresholdPercent / getMemoryThresholdPercent")
void memoryThresholdPercent() {
AlertConfiguration c = newConfig();
c.setMemoryThresholdPercent(90);
assertThat(c.getMemoryThresholdPercent()).isEqualTo(90);
}
// -------------------------------------------------------------------------
// getters / setters — Error alerts
// -------------------------------------------------------------------------
@Test
@DisplayName("setCriticalErrorAlertEnabled / getCriticalErrorAlertEnabled")
void criticalErrorAlertEnabled() {
AlertConfiguration c = newConfig();
c.setCriticalErrorAlertEnabled(false);
assertThat(c.getCriticalErrorAlertEnabled()).isFalse();
}
@Test
@DisplayName("setErrorAlertEnabled / getErrorAlertEnabled")
void errorAlertEnabled() {
AlertConfiguration c = newConfig();
c.setErrorAlertEnabled(false);
assertThat(c.getErrorAlertEnabled()).isFalse();
}
// -------------------------------------------------------------------------
// getters / setters — Connection failure
// -------------------------------------------------------------------------
@Test
@DisplayName("setConnectionFailureAlertEnabled / getConnectionFailureAlertEnabled")
void connectionFailureAlertEnabled() {
AlertConfiguration c = newConfig();
c.setConnectionFailureAlertEnabled(false);
assertThat(c.getConnectionFailureAlertEnabled()).isFalse();
}
@Test
@DisplayName("setConnectionFailureThreshold / getConnectionFailureThreshold")
void connectionFailureThreshold() {
AlertConfiguration c = newConfig();
c.setConnectionFailureThreshold(50);
assertThat(c.getConnectionFailureThreshold()).isEqualTo(50);
}
@Test
@DisplayName("setConnectionFailureWindowMinutes / getConnectionFailureWindowMinutes")
void connectionFailureWindowMinutes() {
AlertConfiguration c = newConfig();
c.setConnectionFailureWindowMinutes(15);
assertThat(c.getConnectionFailureWindowMinutes()).isEqualTo(15);
}
// -------------------------------------------------------------------------
// getters / setters — Notification channels
// -------------------------------------------------------------------------
@Test
@DisplayName("setEmailNotificationsEnabled / getEmailNotificationsEnabled")
void emailNotificationsEnabled() {
AlertConfiguration c = newConfig();
c.setEmailNotificationsEnabled(false);
assertThat(c.getEmailNotificationsEnabled()).isFalse();
}
@Test
@DisplayName("setPushNotificationsEnabled / getPushNotificationsEnabled")
void pushNotificationsEnabled() {
AlertConfiguration c = newConfig();
c.setPushNotificationsEnabled(true);
assertThat(c.getPushNotificationsEnabled()).isTrue();
}
@Test
@DisplayName("setSmsNotificationsEnabled / getSmsNotificationsEnabled")
void smsNotificationsEnabled() {
AlertConfiguration c = newConfig();
c.setSmsNotificationsEnabled(true);
assertThat(c.getSmsNotificationsEnabled()).isTrue();
}
@Test
@DisplayName("setAlertEmailRecipients / getAlertEmailRecipients")
void alertEmailRecipients() {
AlertConfiguration c = newConfig();
c.setAlertEmailRecipients("ops@example.com,dev@example.com");
assertThat(c.getAlertEmailRecipients()).isEqualTo("ops@example.com,dev@example.com");
}
// -------------------------------------------------------------------------
// BaseEntity fields inherited via @Getter/@Setter
// -------------------------------------------------------------------------
@Test
@DisplayName("BaseEntity fields accessible via inherited getters/setters")
void baseEntityFields() {
AlertConfiguration c = newConfig();
UUID id = UUID.randomUUID();
LocalDateTime now = LocalDateTime.now();
c.setId(id);
c.setDateCreation(now);
c.setDateModification(now);
c.setCreePar("admin@test.com");
c.setModifiePar("user@test.com");
c.setVersion(1L);
c.setActif(true);
assertThat(c.getId()).isEqualTo(id);
assertThat(c.getDateCreation()).isEqualTo(now);
assertThat(c.getDateModification()).isEqualTo(now);
assertThat(c.getCreePar()).isEqualTo("admin@test.com");
assertThat(c.getModifiePar()).isEqualTo("user@test.com");
assertThat(c.getVersion()).isEqualTo(1L);
assertThat(c.getActif()).isTrue();
}
// -------------------------------------------------------------------------
// @PrePersist/@PreUpdate callback (ensureSingleton is a no-op)
// -------------------------------------------------------------------------
@Test
@DisplayName("ensureSingleton callback is a no-op and does not throw")
void ensureSingletonNoOp() {
// The @PrePersist/@PreUpdate method has an empty body — just verify it can be
// called via the inherited onCreate/onUpdate chain without exception.
AlertConfiguration c = newConfig();
// Call BaseEntity lifecycle methods directly to cover the branch
c.setDateCreation(null);
c.setActif(null);
// These are normally triggered by JPA; call the superclass hooks via reflection
// would require test-framework support — instead, verify the object state is stable.
assertThat(c).isNotNull();
}
// -------------------------------------------------------------------------
// equals / hashCode / toString
// -------------------------------------------------------------------------
@Test
@DisplayName("equals and hashCode are consistent for same id")
void equalsHashCode() {
UUID id = UUID.randomUUID();
AlertConfiguration a = newConfig();
a.setId(id);
AlertConfiguration b = newConfig();
b.setId(id);
assertThat(a).isEqualTo(b);
assertThat(a.hashCode()).isEqualTo(b.hashCode());
}
@Test
@DisplayName("toString is non-null and non-empty")
void toStringNonNull() {
AlertConfiguration c = newConfig();
assertThat(c.toString()).isNotNull().isNotEmpty();
}
}

View File

@@ -0,0 +1,297 @@
package dev.lions.unionflow.server.entity;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("AlerteLcbFt")
class AlerteLcbFtTest {
// -------------------------------------------------------------------------
// No-args constructor
// -------------------------------------------------------------------------
@Test
@DisplayName("no-args constructor creates non-null instance")
void noArgsConstructor() {
AlerteLcbFt alerte = new AlerteLcbFt();
assertThat(alerte).isNotNull();
}
@Test
@DisplayName("no-args constructor sets traitee = false by default (field initializer)")
void noArgsConstructor_traiteeDefaultFalse() {
// @Builder.Default on traitee is only honoured when using the builder;
// with @NoArgsConstructor the field initializer (= false) still applies.
AlerteLcbFt alerte = new AlerteLcbFt();
assertThat(alerte.getTraitee()).isNull();
// The field carries @Builder.Default so Lombok synthesises a separate
// $default$traitee() method — traitee is null with plain new until set.
}
// -------------------------------------------------------------------------
// Builder
// -------------------------------------------------------------------------
@Test
@DisplayName("builder sets all scalar fields")
void builder_scalarFields() {
LocalDateTime dateAlerte = LocalDateTime.of(2026, 3, 15, 10, 0);
LocalDateTime dateTraitement = LocalDateTime.of(2026, 3, 16, 9, 0);
UUID traitePar = UUID.randomUUID();
AlerteLcbFt alerte = AlerteLcbFt.builder()
.typeAlerte("SEUIL_DEPASSE")
.dateAlerte(dateAlerte)
.description("Transaction suspecte détectée")
.details("{\"ref\":\"TX-001\"}")
.montant(new BigDecimal("5000000.00"))
.seuil(new BigDecimal("3000000.00"))
.typeOperation("TRANSFERT")
.transactionRef("TX-REF-001")
.severite("CRITICAL")
.traitee(true)
.dateTraitement(dateTraitement)
.traitePar(traitePar)
.commentaireTraitement("Vérifié et classé")
.build();
assertThat(alerte.getTypeAlerte()).isEqualTo("SEUIL_DEPASSE");
assertThat(alerte.getDateAlerte()).isEqualTo(dateAlerte);
assertThat(alerte.getDescription()).isEqualTo("Transaction suspecte détectée");
assertThat(alerte.getDetails()).isEqualTo("{\"ref\":\"TX-001\"}");
assertThat(alerte.getMontant()).isEqualByComparingTo("5000000.00");
assertThat(alerte.getSeuil()).isEqualByComparingTo("3000000.00");
assertThat(alerte.getTypeOperation()).isEqualTo("TRANSFERT");
assertThat(alerte.getTransactionRef()).isEqualTo("TX-REF-001");
assertThat(alerte.getSeverite()).isEqualTo("CRITICAL");
assertThat(alerte.getTraitee()).isTrue();
assertThat(alerte.getDateTraitement()).isEqualTo(dateTraitement);
assertThat(alerte.getTraitePar()).isEqualTo(traitePar);
assertThat(alerte.getCommentaireTraitement()).isEqualTo("Vérifié et classé");
}
@Test
@DisplayName("builder default: traitee = false when not explicitly set")
void builder_defaultTraitee() {
AlerteLcbFt alerte = AlerteLcbFt.builder()
.typeAlerte("JUSTIFICATION_MANQUANTE")
.dateAlerte(LocalDateTime.now())
.severite("WARNING")
.build();
assertThat(alerte.getTraitee()).isFalse();
}
@Test
@DisplayName("builder with organisation and membre associations")
void builder_withAssociations() {
Organisation org = new Organisation();
org.setId(UUID.randomUUID());
Membre membre = new Membre();
membre.setId(UUID.randomUUID());
AlerteLcbFt alerte = AlerteLcbFt.builder()
.organisation(org)
.membre(membre)
.typeAlerte("SEUIL_DEPASSE")
.dateAlerte(LocalDateTime.now())
.severite("INFO")
.build();
assertThat(alerte.getOrganisation()).isSameAs(org);
assertThat(alerte.getMembre()).isSameAs(membre);
}
// -------------------------------------------------------------------------
// All-args constructor
// -------------------------------------------------------------------------
@Test
@DisplayName("all-args constructor populates all fields")
void allArgsConstructor() {
Organisation org = new Organisation();
Membre membre = new Membre();
LocalDateTime now = LocalDateTime.now();
UUID traitePar = UUID.randomUUID();
AlerteLcbFt alerte = new AlerteLcbFt(
org,
membre,
"SEUIL_DEPASSE",
now,
"desc",
"{}",
new BigDecimal("1000.00"),
new BigDecimal("500.00"),
"DEPOT",
"TX-123",
"WARNING",
false,
null,
traitePar,
null
);
assertThat(alerte.getOrganisation()).isSameAs(org);
assertThat(alerte.getMembre()).isSameAs(membre);
assertThat(alerte.getTypeAlerte()).isEqualTo("SEUIL_DEPASSE");
assertThat(alerte.getDateAlerte()).isEqualTo(now);
assertThat(alerte.getDescription()).isEqualTo("desc");
assertThat(alerte.getDetails()).isEqualTo("{}");
assertThat(alerte.getMontant()).isEqualByComparingTo("1000.00");
assertThat(alerte.getSeuil()).isEqualByComparingTo("500.00");
assertThat(alerte.getTypeOperation()).isEqualTo("DEPOT");
assertThat(alerte.getTransactionRef()).isEqualTo("TX-123");
assertThat(alerte.getSeverite()).isEqualTo("WARNING");
assertThat(alerte.getTraitee()).isFalse();
assertThat(alerte.getDateTraitement()).isNull();
assertThat(alerte.getTraitePar()).isEqualTo(traitePar);
assertThat(alerte.getCommentaireTraitement()).isNull();
}
// -------------------------------------------------------------------------
// Getters / Setters
// -------------------------------------------------------------------------
@Test
@DisplayName("setters and getters round-trip for all fields")
void settersGetters() {
AlerteLcbFt alerte = new AlerteLcbFt();
Organisation org = new Organisation();
Membre membre = new Membre();
LocalDateTime dateAlerte = LocalDateTime.of(2026, 1, 10, 8, 30);
LocalDateTime dateTraitement = LocalDateTime.of(2026, 1, 11, 12, 0);
UUID traitePar = UUID.randomUUID();
alerte.setOrganisation(org);
alerte.setMembre(membre);
alerte.setTypeAlerte("RETRAIT_ANORMAL");
alerte.setDateAlerte(dateAlerte);
alerte.setDescription("Retrait inhabituel");
alerte.setDetails("{\"note\":\"test\"}");
alerte.setMontant(new BigDecimal("200000.00"));
alerte.setSeuil(new BigDecimal("150000.00"));
alerte.setTypeOperation("RETRAIT");
alerte.setTransactionRef("RET-999");
alerte.setSeverite("INFO");
alerte.setTraitee(true);
alerte.setDateTraitement(dateTraitement);
alerte.setTraitePar(traitePar);
alerte.setCommentaireTraitement("RAS");
assertThat(alerte.getOrganisation()).isSameAs(org);
assertThat(alerte.getMembre()).isSameAs(membre);
assertThat(alerte.getTypeAlerte()).isEqualTo("RETRAIT_ANORMAL");
assertThat(alerte.getDateAlerte()).isEqualTo(dateAlerte);
assertThat(alerte.getDescription()).isEqualTo("Retrait inhabituel");
assertThat(alerte.getDetails()).isEqualTo("{\"note\":\"test\"}");
assertThat(alerte.getMontant()).isEqualByComparingTo("200000.00");
assertThat(alerte.getSeuil()).isEqualByComparingTo("150000.00");
assertThat(alerte.getTypeOperation()).isEqualTo("RETRAIT");
assertThat(alerte.getTransactionRef()).isEqualTo("RET-999");
assertThat(alerte.getSeverite()).isEqualTo("INFO");
assertThat(alerte.getTraitee()).isTrue();
assertThat(alerte.getDateTraitement()).isEqualTo(dateTraitement);
assertThat(alerte.getTraitePar()).isEqualTo(traitePar);
assertThat(alerte.getCommentaireTraitement()).isEqualTo("RAS");
}
// -------------------------------------------------------------------------
// Null-safe optional fields
// -------------------------------------------------------------------------
@Test
@DisplayName("optional fields accept null")
void optionalFieldsAcceptNull() {
AlerteLcbFt alerte = AlerteLcbFt.builder()
.typeAlerte("SEUIL_DEPASSE")
.dateAlerte(LocalDateTime.now())
.severite("CRITICAL")
.description(null)
.details(null)
.montant(null)
.seuil(null)
.typeOperation(null)
.transactionRef(null)
.dateTraitement(null)
.traitePar(null)
.commentaireTraitement(null)
.membre(null)
.build();
assertThat(alerte.getDescription()).isNull();
assertThat(alerte.getDetails()).isNull();
assertThat(alerte.getMontant()).isNull();
assertThat(alerte.getSeuil()).isNull();
assertThat(alerte.getTypeOperation()).isNull();
assertThat(alerte.getTransactionRef()).isNull();
assertThat(alerte.getDateTraitement()).isNull();
assertThat(alerte.getTraitePar()).isNull();
assertThat(alerte.getCommentaireTraitement()).isNull();
assertThat(alerte.getMembre()).isNull();
}
// -------------------------------------------------------------------------
// BaseEntity fields
// -------------------------------------------------------------------------
@Test
@DisplayName("BaseEntity fields accessible via inherited getters/setters")
void baseEntityFields() {
AlerteLcbFt alerte = new AlerteLcbFt();
UUID id = UUID.randomUUID();
LocalDateTime now = LocalDateTime.now();
alerte.setId(id);
alerte.setDateCreation(now);
alerte.setDateModification(now);
alerte.setCreePar("system");
alerte.setModifiePar("admin");
alerte.setVersion(2L);
alerte.setActif(true);
assertThat(alerte.getId()).isEqualTo(id);
assertThat(alerte.getDateCreation()).isEqualTo(now);
assertThat(alerte.getDateModification()).isEqualTo(now);
assertThat(alerte.getCreePar()).isEqualTo("system");
assertThat(alerte.getModifiePar()).isEqualTo("admin");
assertThat(alerte.getVersion()).isEqualTo(2L);
assertThat(alerte.getActif()).isTrue();
}
// -------------------------------------------------------------------------
// equals / hashCode / toString
// -------------------------------------------------------------------------
@Test
@DisplayName("equals and hashCode are consistent for same id")
void equalsHashCode() {
UUID id = UUID.randomUUID();
AlerteLcbFt a = new AlerteLcbFt();
a.setId(id);
AlerteLcbFt b = new AlerteLcbFt();
b.setId(id);
assertThat(a).isEqualTo(b);
assertThat(a.hashCode()).isEqualTo(b.hashCode());
}
@Test
@DisplayName("toString is non-null")
void toStringNonNull() {
AlerteLcbFt alerte = AlerteLcbFt.builder()
.typeAlerte("INFO")
.dateAlerte(LocalDateTime.now())
.severite("INFO")
.build();
assertThat(alerte.toString()).isNotNull();
}
}

View File

@@ -0,0 +1,218 @@
package dev.lions.unionflow.server.entity;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("BackupConfig")
class BackupConfigTest {
// -------------------------------------------------------------------------
// No-args constructor
// -------------------------------------------------------------------------
@Test
@DisplayName("no-args constructor creates non-null instance")
void noArgsConstructor() {
BackupConfig cfg = new BackupConfig();
assertThat(cfg).isNotNull();
}
@Test
@DisplayName("no-args constructor: @Builder.Default fields are null without builder")
void noArgsConstructor_builderDefaultFieldsAreNull() {
// Lombok @Builder.Default with @NoArgsConstructor leaves fields at their
// Java-primitive defaults (null for boxed types) unless a plain field
// initializer is present. BackupConfig uses @Builder.Default, so
// no-args ctor produces null for those fields.
BackupConfig cfg = new BackupConfig();
assertThat(cfg.getAutoBackupEnabled()).isNull();
assertThat(cfg.getFrequency()).isNull();
assertThat(cfg.getRetentionDays()).isNull();
assertThat(cfg.getBackupTime()).isNull();
assertThat(cfg.getIncludeDatabase()).isNull();
assertThat(cfg.getIncludeFiles()).isNull();
assertThat(cfg.getIncludeConfiguration()).isNull();
}
// -------------------------------------------------------------------------
// Builder — defaults
// -------------------------------------------------------------------------
@Test
@DisplayName("builder applies @Builder.Default values when fields not set")
void builder_defaults() {
BackupConfig cfg = BackupConfig.builder().build();
assertThat(cfg.getAutoBackupEnabled()).isTrue();
assertThat(cfg.getFrequency()).isEqualTo("DAILY");
assertThat(cfg.getRetentionDays()).isEqualTo(30);
assertThat(cfg.getBackupTime()).isEqualTo("02:00");
assertThat(cfg.getIncludeDatabase()).isTrue();
assertThat(cfg.getIncludeFiles()).isFalse();
assertThat(cfg.getIncludeConfiguration()).isTrue();
assertThat(cfg.getBackupDirectory()).isNull();
}
// -------------------------------------------------------------------------
// Builder — override all fields
// -------------------------------------------------------------------------
@Test
@DisplayName("builder overrides all @Builder.Default values")
void builder_overrideAllDefaults() {
BackupConfig cfg = BackupConfig.builder()
.autoBackupEnabled(false)
.frequency("WEEKLY")
.retentionDays(90)
.backupTime("03:30")
.includeDatabase(false)
.includeFiles(true)
.includeConfiguration(false)
.backupDirectory("/var/backups/unionflow")
.build();
assertThat(cfg.getAutoBackupEnabled()).isFalse();
assertThat(cfg.getFrequency()).isEqualTo("WEEKLY");
assertThat(cfg.getRetentionDays()).isEqualTo(90);
assertThat(cfg.getBackupTime()).isEqualTo("03:30");
assertThat(cfg.getIncludeDatabase()).isFalse();
assertThat(cfg.getIncludeFiles()).isTrue();
assertThat(cfg.getIncludeConfiguration()).isFalse();
assertThat(cfg.getBackupDirectory()).isEqualTo("/var/backups/unionflow");
}
@Test
@DisplayName("builder: HOURLY frequency")
void builder_hourlyFrequency() {
BackupConfig cfg = BackupConfig.builder()
.frequency("HOURLY")
.retentionDays(7)
.build();
assertThat(cfg.getFrequency()).isEqualTo("HOURLY");
assertThat(cfg.getRetentionDays()).isEqualTo(7);
}
// -------------------------------------------------------------------------
// All-args constructor
// -------------------------------------------------------------------------
@Test
@DisplayName("all-args constructor populates every field")
void allArgsConstructor() {
BackupConfig cfg = new BackupConfig(true, "DAILY", 30, "02:00", true, false, true, "/data/backup");
assertThat(cfg.getAutoBackupEnabled()).isTrue();
assertThat(cfg.getFrequency()).isEqualTo("DAILY");
assertThat(cfg.getRetentionDays()).isEqualTo(30);
assertThat(cfg.getBackupTime()).isEqualTo("02:00");
assertThat(cfg.getIncludeDatabase()).isTrue();
assertThat(cfg.getIncludeFiles()).isFalse();
assertThat(cfg.getIncludeConfiguration()).isTrue();
assertThat(cfg.getBackupDirectory()).isEqualTo("/data/backup");
}
// -------------------------------------------------------------------------
// Getters / Setters (@Data)
// -------------------------------------------------------------------------
@Test
@DisplayName("setters and getters round-trip")
void settersGetters() {
BackupConfig cfg = new BackupConfig();
cfg.setAutoBackupEnabled(true);
cfg.setFrequency("DAILY");
cfg.setRetentionDays(60);
cfg.setBackupTime("04:00");
cfg.setIncludeDatabase(true);
cfg.setIncludeFiles(true);
cfg.setIncludeConfiguration(false);
cfg.setBackupDirectory("/mnt/nas/backups");
assertThat(cfg.getAutoBackupEnabled()).isTrue();
assertThat(cfg.getFrequency()).isEqualTo("DAILY");
assertThat(cfg.getRetentionDays()).isEqualTo(60);
assertThat(cfg.getBackupTime()).isEqualTo("04:00");
assertThat(cfg.getIncludeDatabase()).isTrue();
assertThat(cfg.getIncludeFiles()).isTrue();
assertThat(cfg.getIncludeConfiguration()).isFalse();
assertThat(cfg.getBackupDirectory()).isEqualTo("/mnt/nas/backups");
}
@Test
@DisplayName("backupDirectory accepts null")
void backupDirectoryNull() {
BackupConfig cfg = BackupConfig.builder().build();
cfg.setBackupDirectory(null);
assertThat(cfg.getBackupDirectory()).isNull();
}
// -------------------------------------------------------------------------
// BaseEntity fields
// -------------------------------------------------------------------------
@Test
@DisplayName("BaseEntity fields accessible via inherited getters/setters")
void baseEntityFields() {
BackupConfig cfg = new BackupConfig();
UUID id = UUID.randomUUID();
LocalDateTime now = LocalDateTime.now();
cfg.setId(id);
cfg.setDateCreation(now);
cfg.setDateModification(now);
cfg.setCreePar("system@test.com");
cfg.setModifiePar("admin@test.com");
cfg.setVersion(3L);
cfg.setActif(false);
assertThat(cfg.getId()).isEqualTo(id);
assertThat(cfg.getDateCreation()).isEqualTo(now);
assertThat(cfg.getDateModification()).isEqualTo(now);
assertThat(cfg.getCreePar()).isEqualTo("system@test.com");
assertThat(cfg.getModifiePar()).isEqualTo("admin@test.com");
assertThat(cfg.getVersion()).isEqualTo(3L);
assertThat(cfg.getActif()).isFalse();
}
// -------------------------------------------------------------------------
// equals / hashCode / toString (@Data + @EqualsAndHashCode(callSuper = true))
// -------------------------------------------------------------------------
@Test
@DisplayName("equals and hashCode are consistent for identical content")
void equalsHashCode() {
BackupConfig a = BackupConfig.builder()
.frequency("DAILY")
.retentionDays(30)
.build();
BackupConfig b = BackupConfig.builder()
.frequency("DAILY")
.retentionDays(30)
.build();
// Both have null id (BaseEntity), so equals based on field values
assertThat(a).isEqualTo(b);
assertThat(a.hashCode()).isEqualTo(b.hashCode());
}
@Test
@DisplayName("equals returns false for different frequency")
void equalsReturnsFalseForDifferentFrequency() {
BackupConfig a = BackupConfig.builder().frequency("DAILY").build();
BackupConfig b = BackupConfig.builder().frequency("WEEKLY").build();
assertThat(a).isNotEqualTo(b);
}
@Test
@DisplayName("toString is non-null and non-empty")
void toStringNonNull() {
BackupConfig cfg = BackupConfig.builder().build();
assertThat(cfg.toString()).isNotNull().isNotEmpty();
}
}

View File

@@ -0,0 +1,289 @@
package dev.lions.unionflow.server.entity;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("BackupRecord")
class BackupRecordTest {
// -------------------------------------------------------------------------
// No-args constructor
// -------------------------------------------------------------------------
@Test
@DisplayName("no-args constructor creates non-null instance")
void noArgsConstructor() {
BackupRecord rec = new BackupRecord();
assertThat(rec).isNotNull();
}
@Test
@DisplayName("no-args constructor: @Builder.Default fields are null (no builder involved)")
void noArgsConstructor_builderDefaultFieldsAreNull() {
BackupRecord rec = new BackupRecord();
// @Builder.Default means the no-arg ctor leaves these as null
assertThat(rec.getIncludesDatabase()).isNull();
assertThat(rec.getIncludesFiles()).isNull();
assertThat(rec.getIncludesConfiguration()).isNull();
}
// -------------------------------------------------------------------------
// Builder — defaults
// -------------------------------------------------------------------------
@Test
@DisplayName("builder applies @Builder.Default values: includesDatabase=true, includesFiles=false, includesConfiguration=true")
void builder_defaults() {
BackupRecord rec = BackupRecord.builder()
.name("backup-2026-03-15")
.type("AUTO")
.status("COMPLETED")
.build();
assertThat(rec.getIncludesDatabase()).isTrue();
assertThat(rec.getIncludesFiles()).isFalse();
assertThat(rec.getIncludesConfiguration()).isTrue();
}
// -------------------------------------------------------------------------
// Builder — all fields
// -------------------------------------------------------------------------
@Test
@DisplayName("builder sets every field when all are provided")
void builder_allFields() {
LocalDateTime completedAt = LocalDateTime.of(2026, 3, 15, 3, 0, 45);
BackupRecord rec = BackupRecord.builder()
.name("backup-manual-2026-03-15")
.description("Pre-migration snapshot")
.type("MANUAL")
.sizeBytes(524288000L)
.status("COMPLETED")
.completedAt(completedAt)
.createdBy("admin@unionflow.test")
.includesDatabase(true)
.includesFiles(true)
.includesConfiguration(true)
.filePath("/var/backups/unionflow/backup-manual-2026-03-15.tar.gz")
.errorMessage(null)
.build();
assertThat(rec.getName()).isEqualTo("backup-manual-2026-03-15");
assertThat(rec.getDescription()).isEqualTo("Pre-migration snapshot");
assertThat(rec.getType()).isEqualTo("MANUAL");
assertThat(rec.getSizeBytes()).isEqualTo(524288000L);
assertThat(rec.getStatus()).isEqualTo("COMPLETED");
assertThat(rec.getCompletedAt()).isEqualTo(completedAt);
assertThat(rec.getCreatedBy()).isEqualTo("admin@unionflow.test");
assertThat(rec.getIncludesDatabase()).isTrue();
assertThat(rec.getIncludesFiles()).isTrue();
assertThat(rec.getIncludesConfiguration()).isTrue();
assertThat(rec.getFilePath()).isEqualTo("/var/backups/unionflow/backup-manual-2026-03-15.tar.gz");
assertThat(rec.getErrorMessage()).isNull();
}
@Test
@DisplayName("builder: RESTORE_POINT type")
void builder_restorePoint() {
BackupRecord rec = BackupRecord.builder()
.name("restore-point-001")
.type("RESTORE_POINT")
.status("COMPLETED")
.build();
assertThat(rec.getType()).isEqualTo("RESTORE_POINT");
}
@Test
@DisplayName("builder: IN_PROGRESS status and no completedAt")
void builder_inProgress() {
BackupRecord rec = BackupRecord.builder()
.name("backup-in-progress")
.type("AUTO")
.status("IN_PROGRESS")
.build();
assertThat(rec.getStatus()).isEqualTo("IN_PROGRESS");
assertThat(rec.getCompletedAt()).isNull();
}
@Test
@DisplayName("builder: FAILED status with errorMessage")
void builder_failed() {
BackupRecord rec = BackupRecord.builder()
.name("backup-failed")
.type("AUTO")
.status("FAILED")
.errorMessage("Disk quota exceeded")
.build();
assertThat(rec.getStatus()).isEqualTo("FAILED");
assertThat(rec.getErrorMessage()).isEqualTo("Disk quota exceeded");
}
// -------------------------------------------------------------------------
// All-args constructor
// -------------------------------------------------------------------------
@Test
@DisplayName("all-args constructor populates every field")
void allArgsConstructor() {
LocalDateTime completedAt = LocalDateTime.of(2026, 1, 1, 2, 5);
BackupRecord rec = new BackupRecord(
"daily-backup",
"Automated daily backup",
"AUTO",
1048576L,
"COMPLETED",
completedAt,
"scheduler",
true,
false,
true,
"/backups/daily.tar.gz",
null
);
assertThat(rec.getName()).isEqualTo("daily-backup");
assertThat(rec.getDescription()).isEqualTo("Automated daily backup");
assertThat(rec.getType()).isEqualTo("AUTO");
assertThat(rec.getSizeBytes()).isEqualTo(1048576L);
assertThat(rec.getStatus()).isEqualTo("COMPLETED");
assertThat(rec.getCompletedAt()).isEqualTo(completedAt);
assertThat(rec.getCreatedBy()).isEqualTo("scheduler");
assertThat(rec.getIncludesDatabase()).isTrue();
assertThat(rec.getIncludesFiles()).isFalse();
assertThat(rec.getIncludesConfiguration()).isTrue();
assertThat(rec.getFilePath()).isEqualTo("/backups/daily.tar.gz");
assertThat(rec.getErrorMessage()).isNull();
}
// -------------------------------------------------------------------------
// Getters / Setters (@Data)
// -------------------------------------------------------------------------
@Test
@DisplayName("setters and getters round-trip for all fields")
void settersGetters() {
BackupRecord rec = new BackupRecord();
LocalDateTime completedAt = LocalDateTime.of(2026, 4, 1, 5, 0);
rec.setName("weekly-backup");
rec.setDescription("Weekly archive");
rec.setType("AUTO");
rec.setSizeBytes(2097152L);
rec.setStatus("COMPLETED");
rec.setCompletedAt(completedAt);
rec.setCreatedBy("admin");
rec.setIncludesDatabase(true);
rec.setIncludesFiles(false);
rec.setIncludesConfiguration(true);
rec.setFilePath("/archives/weekly.tar.gz");
rec.setErrorMessage(null);
assertThat(rec.getName()).isEqualTo("weekly-backup");
assertThat(rec.getDescription()).isEqualTo("Weekly archive");
assertThat(rec.getType()).isEqualTo("AUTO");
assertThat(rec.getSizeBytes()).isEqualTo(2097152L);
assertThat(rec.getStatus()).isEqualTo("COMPLETED");
assertThat(rec.getCompletedAt()).isEqualTo(completedAt);
assertThat(rec.getCreatedBy()).isEqualTo("admin");
assertThat(rec.getIncludesDatabase()).isTrue();
assertThat(rec.getIncludesFiles()).isFalse();
assertThat(rec.getIncludesConfiguration()).isTrue();
assertThat(rec.getFilePath()).isEqualTo("/archives/weekly.tar.gz");
assertThat(rec.getErrorMessage()).isNull();
}
@Test
@DisplayName("optional fields accept null")
void optionalFieldsAcceptNull() {
BackupRecord rec = new BackupRecord();
rec.setDescription(null);
rec.setSizeBytes(null);
rec.setCompletedAt(null);
rec.setCreatedBy(null);
rec.setFilePath(null);
rec.setErrorMessage(null);
assertThat(rec.getDescription()).isNull();
assertThat(rec.getSizeBytes()).isNull();
assertThat(rec.getCompletedAt()).isNull();
assertThat(rec.getCreatedBy()).isNull();
assertThat(rec.getFilePath()).isNull();
assertThat(rec.getErrorMessage()).isNull();
}
// -------------------------------------------------------------------------
// BaseEntity fields
// -------------------------------------------------------------------------
@Test
@DisplayName("BaseEntity fields accessible via inherited getters/setters")
void baseEntityFields() {
BackupRecord rec = new BackupRecord();
UUID id = UUID.randomUUID();
LocalDateTime now = LocalDateTime.now();
rec.setId(id);
rec.setDateCreation(now);
rec.setDateModification(now);
rec.setCreePar("system");
rec.setModifiePar("operator");
rec.setVersion(5L);
rec.setActif(true);
assertThat(rec.getId()).isEqualTo(id);
assertThat(rec.getDateCreation()).isEqualTo(now);
assertThat(rec.getDateModification()).isEqualTo(now);
assertThat(rec.getCreePar()).isEqualTo("system");
assertThat(rec.getModifiePar()).isEqualTo("operator");
assertThat(rec.getVersion()).isEqualTo(5L);
assertThat(rec.getActif()).isTrue();
}
// -------------------------------------------------------------------------
// equals / hashCode / toString
// -------------------------------------------------------------------------
@Test
@DisplayName("equals and hashCode consistent for identical content")
void equalsHashCode() {
BackupRecord a = BackupRecord.builder()
.name("rec-A")
.type("AUTO")
.status("COMPLETED")
.build();
BackupRecord b = BackupRecord.builder()
.name("rec-A")
.type("AUTO")
.status("COMPLETED")
.build();
assertThat(a).isEqualTo(b);
assertThat(a.hashCode()).isEqualTo(b.hashCode());
}
@Test
@DisplayName("equals returns false for different name")
void equalsReturnsFalseForDifferentName() {
BackupRecord a = BackupRecord.builder().name("alpha").type("AUTO").status("COMPLETED").build();
BackupRecord b = BackupRecord.builder().name("beta").type("AUTO").status("COMPLETED").build();
assertThat(a).isNotEqualTo(b);
}
@Test
@DisplayName("toString is non-null and non-empty")
void toStringNonNull() {
BackupRecord rec = BackupRecord.builder()
.name("test")
.type("MANUAL")
.status("COMPLETED")
.build();
assertThat(rec.toString()).isNotNull().isNotEmpty();
}
}

View File

@@ -0,0 +1,279 @@
package dev.lions.unionflow.server.entity;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("BaremeCotisationRole")
class BaremeCotisationRoleTest {
// -------------------------------------------------------------------------
// No-args constructor
// -------------------------------------------------------------------------
@Test
@DisplayName("no-args constructor creates non-null instance")
void noArgsConstructor() {
BaremeCotisationRole bareme = new BaremeCotisationRole();
assertThat(bareme).isNotNull();
}
@Test
@DisplayName("no-args constructor: @Builder.Default fields are null (no builder involved)")
void noArgsConstructor_builderDefaultsAreNull() {
BaremeCotisationRole bareme = new BaremeCotisationRole();
// @Builder.Default fields are null when constructed with no-arg ctor
assertThat(bareme.getMontantMensuel()).isNull();
assertThat(bareme.getMontantAnnuel()).isNull();
}
// -------------------------------------------------------------------------
// Builder — defaults
// -------------------------------------------------------------------------
@Test
@DisplayName("builder default: montantMensuel = ZERO, montantAnnuel = ZERO")
void builder_defaultAmounts() {
BaremeCotisationRole bareme = BaremeCotisationRole.builder()
.roleOrg("MEMBRE_ORDINAIRE")
.build();
assertThat(bareme.getMontantMensuel()).isEqualByComparingTo(BigDecimal.ZERO);
assertThat(bareme.getMontantAnnuel()).isEqualByComparingTo(BigDecimal.ZERO);
}
// -------------------------------------------------------------------------
// Builder — all fields
// -------------------------------------------------------------------------
@Test
@DisplayName("builder sets all fields for PRESIDENT role")
void builder_president() {
Organisation org = new Organisation();
org.setId(UUID.randomUUID());
BaremeCotisationRole bareme = BaremeCotisationRole.builder()
.organisation(org)
.roleOrg("PRESIDENT")
.montantMensuel(new BigDecimal("0.00"))
.montantAnnuel(new BigDecimal("0.00"))
.description("Exonéré — bureau exécutif")
.build();
assertThat(bareme.getOrganisation()).isSameAs(org);
assertThat(bareme.getRoleOrg()).isEqualTo("PRESIDENT");
assertThat(bareme.getMontantMensuel()).isEqualByComparingTo("0.00");
assertThat(bareme.getMontantAnnuel()).isEqualByComparingTo("0.00");
assertThat(bareme.getDescription()).isEqualTo("Exonéré — bureau exécutif");
}
@Test
@DisplayName("builder sets all fields for TRESORIER role with positive amounts")
void builder_tresorier() {
Organisation org = new Organisation();
BaremeCotisationRole bareme = BaremeCotisationRole.builder()
.organisation(org)
.roleOrg("TRESORIER")
.montantMensuel(new BigDecimal("2500.00"))
.montantAnnuel(new BigDecimal("25000.00"))
.description("Taux réduit bureau exécutif")
.build();
assertThat(bareme.getRoleOrg()).isEqualTo("TRESORIER");
assertThat(bareme.getMontantMensuel()).isEqualByComparingTo("2500.00");
assertThat(bareme.getMontantAnnuel()).isEqualByComparingTo("25000.00");
assertThat(bareme.getDescription()).isEqualTo("Taux réduit bureau exécutif");
}
@Test
@DisplayName("builder: SECRETAIRE role, no description")
void builder_secretaireNoDescription() {
BaremeCotisationRole bareme = BaremeCotisationRole.builder()
.roleOrg("SECRETAIRE")
.montantMensuel(new BigDecimal("3000.00"))
.montantAnnuel(new BigDecimal("30000.00"))
.build();
assertThat(bareme.getRoleOrg()).isEqualTo("SECRETAIRE");
assertThat(bareme.getMontantMensuel()).isEqualByComparingTo("3000.00");
assertThat(bareme.getMontantAnnuel()).isEqualByComparingTo("30000.00");
assertThat(bareme.getDescription()).isNull();
}
// -------------------------------------------------------------------------
// All-args constructor
// -------------------------------------------------------------------------
@Test
@DisplayName("all-args constructor populates every field")
void allArgsConstructor() {
Organisation org = new Organisation();
org.setId(UUID.randomUUID());
BaremeCotisationRole bareme = new BaremeCotisationRole(
org,
"MEMBRE_ORDINAIRE",
new BigDecimal("5000.00"),
new BigDecimal("50000.00"),
"Tarif standard membres"
);
assertThat(bareme.getOrganisation()).isSameAs(org);
assertThat(bareme.getRoleOrg()).isEqualTo("MEMBRE_ORDINAIRE");
assertThat(bareme.getMontantMensuel()).isEqualByComparingTo("5000.00");
assertThat(bareme.getMontantAnnuel()).isEqualByComparingTo("50000.00");
assertThat(bareme.getDescription()).isEqualTo("Tarif standard membres");
}
// -------------------------------------------------------------------------
// Getters / Setters (@Data)
// -------------------------------------------------------------------------
@Test
@DisplayName("setters and getters round-trip for all fields")
void settersGetters() {
BaremeCotisationRole bareme = new BaremeCotisationRole();
Organisation org = new Organisation();
org.setId(UUID.randomUUID());
bareme.setOrganisation(org);
bareme.setRoleOrg("VICE_PRESIDENT");
bareme.setMontantMensuel(new BigDecimal("1500.50"));
bareme.setMontantAnnuel(new BigDecimal("15005.00"));
bareme.setDescription("VP taux spécial");
assertThat(bareme.getOrganisation()).isSameAs(org);
assertThat(bareme.getRoleOrg()).isEqualTo("VICE_PRESIDENT");
assertThat(bareme.getMontantMensuel()).isEqualByComparingTo("1500.50");
assertThat(bareme.getMontantAnnuel()).isEqualByComparingTo("15005.00");
assertThat(bareme.getDescription()).isEqualTo("VP taux spécial");
}
@Test
@DisplayName("description accepts null (optional field)")
void descriptionAcceptsNull() {
BaremeCotisationRole bareme = new BaremeCotisationRole();
bareme.setDescription(null);
assertThat(bareme.getDescription()).isNull();
}
@Test
@DisplayName("organisation can be set to null")
void organisationAcceptsNull() {
BaremeCotisationRole bareme = new BaremeCotisationRole();
bareme.setOrganisation(null);
assertThat(bareme.getOrganisation()).isNull();
}
@Test
@DisplayName("amounts can be set to BigDecimal.ZERO")
void amountsZero() {
BaremeCotisationRole bareme = new BaremeCotisationRole();
bareme.setMontantMensuel(BigDecimal.ZERO);
bareme.setMontantAnnuel(BigDecimal.ZERO);
assertThat(bareme.getMontantMensuel()).isEqualByComparingTo(BigDecimal.ZERO);
assertThat(bareme.getMontantAnnuel()).isEqualByComparingTo(BigDecimal.ZERO);
}
// -------------------------------------------------------------------------
// BaseEntity fields
// -------------------------------------------------------------------------
@Test
@DisplayName("BaseEntity fields accessible via inherited getters/setters")
void baseEntityFields() {
BaremeCotisationRole bareme = new BaremeCotisationRole();
UUID id = UUID.randomUUID();
LocalDateTime now = LocalDateTime.now();
bareme.setId(id);
bareme.setDateCreation(now);
bareme.setDateModification(now);
bareme.setCreePar("admin@test.com");
bareme.setModifiePar("ops@test.com");
bareme.setVersion(1L);
bareme.setActif(true);
assertThat(bareme.getId()).isEqualTo(id);
assertThat(bareme.getDateCreation()).isEqualTo(now);
assertThat(bareme.getDateModification()).isEqualTo(now);
assertThat(bareme.getCreePar()).isEqualTo("admin@test.com");
assertThat(bareme.getModifiePar()).isEqualTo("ops@test.com");
assertThat(bareme.getVersion()).isEqualTo(1L);
assertThat(bareme.getActif()).isTrue();
}
// -------------------------------------------------------------------------
// equals / hashCode / toString (@Data + @EqualsAndHashCode(callSuper = true))
// -------------------------------------------------------------------------
@Test
@DisplayName("equals and hashCode consistent for identical content")
void equalsHashCode() {
BaremeCotisationRole a = BaremeCotisationRole.builder()
.roleOrg("PRESIDENT")
.montantMensuel(BigDecimal.ZERO)
.montantAnnuel(BigDecimal.ZERO)
.build();
BaremeCotisationRole b = BaremeCotisationRole.builder()
.roleOrg("PRESIDENT")
.montantMensuel(BigDecimal.ZERO)
.montantAnnuel(BigDecimal.ZERO)
.build();
assertThat(a).isEqualTo(b);
assertThat(a.hashCode()).isEqualTo(b.hashCode());
}
@Test
@DisplayName("equals returns false for different roleOrg")
void equalsReturnsFalseForDifferentRole() {
BaremeCotisationRole a = BaremeCotisationRole.builder()
.roleOrg("PRESIDENT")
.montantMensuel(BigDecimal.ZERO)
.montantAnnuel(BigDecimal.ZERO)
.build();
BaremeCotisationRole b = BaremeCotisationRole.builder()
.roleOrg("TRESORIER")
.montantMensuel(BigDecimal.ZERO)
.montantAnnuel(BigDecimal.ZERO)
.build();
assertThat(a).isNotEqualTo(b);
}
@Test
@DisplayName("toString is non-null and non-empty")
void toStringNonNull() {
BaremeCotisationRole bareme = BaremeCotisationRole.builder()
.roleOrg("MEMBRE_ORDINAIRE")
.build();
assertThat(bareme.toString()).isNotNull().isNotEmpty();
}
// -------------------------------------------------------------------------
// Representative role values (no enum — plain String column)
// -------------------------------------------------------------------------
@Test
@DisplayName("all representative role values can be stored and retrieved")
void representativeRoleValues() {
String[] roles = {
"PRESIDENT", "VICE_PRESIDENT", "TRESORIER", "TRESORIER_ADJOINT",
"SECRETAIRE", "SECRETAIRE_ADJOINT", "MEMBRE_ORDINAIRE", "AUDITEUR"
};
for (String role : roles) {
BaremeCotisationRole bareme = BaremeCotisationRole.builder()
.roleOrg(role)
.build();
assertThat(bareme.getRoleOrg()).as("role: %s", role).isEqualTo(role);
}
}
}

View File

@@ -0,0 +1,404 @@
package dev.lions.unionflow.server.entity;
import dev.lions.unionflow.server.api.enums.membre.NiveauRisqueKyc;
import dev.lions.unionflow.server.api.enums.membre.StatutKyc;
import dev.lions.unionflow.server.api.enums.membre.TypePieceIdentite;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("KycDossier entity")
class KycDossierTest {
// -------------------------------------------------------------------------
// No-args constructor
// -------------------------------------------------------------------------
@Test
@DisplayName("no-args constructor creates instance with default field values")
void noArgsConstructor_createsInstanceWithDefaults() {
KycDossier dossier = new KycDossier();
assertThat(dossier).isNotNull();
assertThat(dossier.getMembre()).isNull();
assertThat(dossier.getTypePiece()).isNull();
assertThat(dossier.getNumeroPiece()).isNull();
assertThat(dossier.getStatut()).isNull();
assertThat(dossier.getNiveauRisque()).isNull();
assertThat(dossier.getScoreRisque()).isZero();
assertThat(dossier.isEstPep()).isFalse();
}
// -------------------------------------------------------------------------
// Builder — all fields
// -------------------------------------------------------------------------
@Nested
@DisplayName("Builder")
class BuilderTests {
@Test
@DisplayName("builder sets all explicit fields")
void builder_setsAllFields() {
UUID validateurId = UUID.randomUUID();
LocalDate expiration = LocalDate.of(2030, 6, 15);
LocalDateTime now = LocalDateTime.now();
KycDossier dossier = KycDossier.builder()
.typePiece(TypePieceIdentite.PASSEPORT)
.numeroPiece("AB123456")
.dateExpirationPiece(expiration)
.pieceIdentiteRectoFileId("file-recto-001")
.pieceIdentiteVersoFileId("file-verso-001")
.justifDomicileFileId("file-justif-001")
.statut(StatutKyc.EN_COURS)
.niveauRisque(NiveauRisqueKyc.MOYEN)
.scoreRisque(55)
.estPep(true)
.nationalite("SEN")
.dateVerification(now)
.validateurId(validateurId)
.notesValidateur("Dossier complet")
.anneeReference(2026)
.build();
assertThat(dossier.getTypePiece()).isEqualTo(TypePieceIdentite.PASSEPORT);
assertThat(dossier.getNumeroPiece()).isEqualTo("AB123456");
assertThat(dossier.getDateExpirationPiece()).isEqualTo(expiration);
assertThat(dossier.getPieceIdentiteRectoFileId()).isEqualTo("file-recto-001");
assertThat(dossier.getPieceIdentiteVersoFileId()).isEqualTo("file-verso-001");
assertThat(dossier.getJustifDomicileFileId()).isEqualTo("file-justif-001");
assertThat(dossier.getStatut()).isEqualTo(StatutKyc.EN_COURS);
assertThat(dossier.getNiveauRisque()).isEqualTo(NiveauRisqueKyc.MOYEN);
assertThat(dossier.getScoreRisque()).isEqualTo(55);
assertThat(dossier.isEstPep()).isTrue();
assertThat(dossier.getNationalite()).isEqualTo("SEN");
assertThat(dossier.getDateVerification()).isEqualTo(now);
assertThat(dossier.getValidateurId()).isEqualTo(validateurId);
assertThat(dossier.getNotesValidateur()).isEqualTo("Dossier complet");
assertThat(dossier.getAnneeReference()).isEqualTo(2026);
}
@Test
@DisplayName("builder uses default statut NON_VERIFIE when not specified")
void builder_defaultStatutIsNonVerifie() {
KycDossier dossier = KycDossier.builder()
.numeroPiece("XY999")
.typePiece(TypePieceIdentite.CNI)
.build();
assertThat(dossier.getStatut()).isEqualTo(StatutKyc.NON_VERIFIE);
}
@Test
@DisplayName("builder uses default niveauRisque FAIBLE when not specified")
void builder_defaultNiveauRisqueIsFaible() {
KycDossier dossier = KycDossier.builder()
.numeroPiece("XY999")
.typePiece(TypePieceIdentite.CNI)
.build();
assertThat(dossier.getNiveauRisque()).isEqualTo(NiveauRisqueKyc.FAIBLE);
}
@Test
@DisplayName("builder uses default scoreRisque 0 when not specified")
void builder_defaultScoreRisqueIsZero() {
KycDossier dossier = KycDossier.builder().build();
assertThat(dossier.getScoreRisque()).isZero();
}
@Test
@DisplayName("builder uses default estPep false when not specified")
void builder_defaultEstPepIsFalse() {
KycDossier dossier = KycDossier.builder().build();
assertThat(dossier.isEstPep()).isFalse();
}
@Test
@DisplayName("builder uses current year as default anneeReference")
void builder_defaultAnneeReferenceIsCurrentYear() {
KycDossier dossier = KycDossier.builder().build();
assertThat(dossier.getAnneeReference()).isEqualTo(LocalDate.now().getYear());
}
}
// -------------------------------------------------------------------------
// All-args constructor
// -------------------------------------------------------------------------
@Test
@DisplayName("all-args constructor (via setter chain) round-trips correctly")
void allArgsConstructor_roundTrips() {
// KycDossier's @AllArgsConstructor includes parent fields via Lombok,
// but since we cannot call super fields directly here, we verify via setters.
KycDossier dossier = new KycDossier();
UUID id = UUID.randomUUID();
dossier.setId(id);
dossier.setNumeroPiece("CNI-001");
dossier.setTypePiece(TypePieceIdentite.CNI);
dossier.setStatut(StatutKyc.VERIFIE);
dossier.setNiveauRisque(NiveauRisqueKyc.ELEVE);
assertThat(dossier.getId()).isEqualTo(id);
assertThat(dossier.getNumeroPiece()).isEqualTo("CNI-001");
assertThat(dossier.getTypePiece()).isEqualTo(TypePieceIdentite.CNI);
assertThat(dossier.getStatut()).isEqualTo(StatutKyc.VERIFIE);
assertThat(dossier.getNiveauRisque()).isEqualTo(NiveauRisqueKyc.ELEVE);
}
// -------------------------------------------------------------------------
// Getters / Setters
// -------------------------------------------------------------------------
@Nested
@DisplayName("Getters and Setters")
class GettersSettersTests {
@Test
@DisplayName("setMembre / getMembre round-trips")
void membre_roundTrips() {
Membre membre = new Membre();
KycDossier dossier = new KycDossier();
dossier.setMembre(membre);
assertThat(dossier.getMembre()).isSameAs(membre);
}
@Test
@DisplayName("setNumeroPiece / getNumeroPiece round-trips")
void numeroPiece_roundTrips() {
KycDossier dossier = new KycDossier();
dossier.setNumeroPiece("PASS-9876");
assertThat(dossier.getNumeroPiece()).isEqualTo("PASS-9876");
}
@Test
@DisplayName("setDateExpirationPiece / getDateExpirationPiece round-trips")
void dateExpirationPiece_roundTrips() {
LocalDate date = LocalDate.of(2028, 12, 31);
KycDossier dossier = new KycDossier();
dossier.setDateExpirationPiece(date);
assertThat(dossier.getDateExpirationPiece()).isEqualTo(date);
}
@Test
@DisplayName("setScoreRisque / getScoreRisque round-trips")
void scoreRisque_roundTrips() {
KycDossier dossier = new KycDossier();
dossier.setScoreRisque(75);
assertThat(dossier.getScoreRisque()).isEqualTo(75);
}
@Test
@DisplayName("setEstPep / isEstPep round-trips")
void estPep_roundTrips() {
KycDossier dossier = new KycDossier();
dossier.setEstPep(true);
assertThat(dossier.isEstPep()).isTrue();
dossier.setEstPep(false);
assertThat(dossier.isEstPep()).isFalse();
}
@Test
@DisplayName("setNationalite / getNationalite round-trips")
void nationalite_roundTrips() {
KycDossier dossier = new KycDossier();
dossier.setNationalite("CIV");
assertThat(dossier.getNationalite()).isEqualTo("CIV");
}
@Test
@DisplayName("setDateVerification / getDateVerification round-trips")
void dateVerification_roundTrips() {
LocalDateTime dt = LocalDateTime.of(2026, 3, 10, 14, 30);
KycDossier dossier = new KycDossier();
dossier.setDateVerification(dt);
assertThat(dossier.getDateVerification()).isEqualTo(dt);
}
@Test
@DisplayName("setValidateurId / getValidateurId round-trips")
void validateurId_roundTrips() {
UUID uuid = UUID.randomUUID();
KycDossier dossier = new KycDossier();
dossier.setValidateurId(uuid);
assertThat(dossier.getValidateurId()).isEqualTo(uuid);
}
@Test
@DisplayName("setNotesValidateur / getNotesValidateur round-trips")
void notesValidateur_roundTrips() {
KycDossier dossier = new KycDossier();
dossier.setNotesValidateur("Notes de validation");
assertThat(dossier.getNotesValidateur()).isEqualTo("Notes de validation");
}
@Test
@DisplayName("setAnneeReference / getAnneeReference round-trips")
void anneeReference_roundTrips() {
KycDossier dossier = new KycDossier();
dossier.setAnneeReference(2025);
assertThat(dossier.getAnneeReference()).isEqualTo(2025);
}
@Test
@DisplayName("file IDs round-trip")
void fileIds_roundTrip() {
KycDossier dossier = new KycDossier();
dossier.setPieceIdentiteRectoFileId("recto-42");
dossier.setPieceIdentiteVersoFileId("verso-42");
dossier.setJustifDomicileFileId("domicile-42");
assertThat(dossier.getPieceIdentiteRectoFileId()).isEqualTo("recto-42");
assertThat(dossier.getPieceIdentiteVersoFileId()).isEqualTo("verso-42");
assertThat(dossier.getJustifDomicileFileId()).isEqualTo("domicile-42");
}
}
// -------------------------------------------------------------------------
// Business method: isPieceExpiree()
// -------------------------------------------------------------------------
@Nested
@DisplayName("isPieceExpiree()")
class IsPieceExpireeTests {
@Test
@DisplayName("returns true when dateExpirationPiece is in the past")
void isPieceExpiree_returnsTrue_whenExpired() {
KycDossier dossier = KycDossier.builder()
.dateExpirationPiece(LocalDate.now().minusDays(1))
.build();
assertThat(dossier.isPieceExpiree()).isTrue();
}
@Test
@DisplayName("returns false when dateExpirationPiece is in the future")
void isPieceExpiree_returnsFalse_whenNotExpired() {
KycDossier dossier = KycDossier.builder()
.dateExpirationPiece(LocalDate.now().plusYears(1))
.build();
assertThat(dossier.isPieceExpiree()).isFalse();
}
@Test
@DisplayName("returns false when dateExpirationPiece is null")
void isPieceExpiree_returnsFalse_whenNull() {
KycDossier dossier = KycDossier.builder().build();
assertThat(dossier.isPieceExpiree()).isFalse();
}
@Test
@DisplayName("returns false when dateExpirationPiece is today")
void isPieceExpiree_returnsFalse_whenToday() {
KycDossier dossier = KycDossier.builder()
.dateExpirationPiece(LocalDate.now())
.build();
// isBefore(now) is false for today
assertThat(dossier.isPieceExpiree()).isFalse();
}
}
// -------------------------------------------------------------------------
// Enum coverage: StatutKyc
// -------------------------------------------------------------------------
@Test
@DisplayName("all StatutKyc values are assignable")
void statutKyc_allValues() {
KycDossier dossier = new KycDossier();
for (StatutKyc statut : StatutKyc.values()) {
dossier.setStatut(statut);
assertThat(dossier.getStatut()).isEqualTo(statut);
}
assertThat(StatutKyc.NON_VERIFIE.getLibelle()).isEqualTo("Non vérifié");
assertThat(StatutKyc.EN_COURS.getLibelle()).isEqualTo("En cours");
assertThat(StatutKyc.VERIFIE.getLibelle()).isEqualTo("Vérifié");
assertThat(StatutKyc.REFUSE.getLibelle()).isEqualTo("Refusé");
}
// -------------------------------------------------------------------------
// Enum coverage: NiveauRisqueKyc
// -------------------------------------------------------------------------
@Test
@DisplayName("all NiveauRisqueKyc values are assignable")
void niveauRisqueKyc_allValues() {
KycDossier dossier = new KycDossier();
for (NiveauRisqueKyc niveau : NiveauRisqueKyc.values()) {
dossier.setNiveauRisque(niveau);
assertThat(dossier.getNiveauRisque()).isEqualTo(niveau);
}
assertThat(NiveauRisqueKyc.FAIBLE.getLibelle()).isEqualTo("Risque faible");
assertThat(NiveauRisqueKyc.MOYEN.getLibelle()).isEqualTo("Risque moyen");
assertThat(NiveauRisqueKyc.ELEVE.getLibelle()).isEqualTo("Risque élevé");
assertThat(NiveauRisqueKyc.CRITIQUE.getLibelle()).isEqualTo("Risque critique");
assertThat(NiveauRisqueKyc.FAIBLE.getScoreMin()).isZero();
assertThat(NiveauRisqueKyc.FAIBLE.getScoreMax()).isEqualTo(39);
assertThat(NiveauRisqueKyc.MOYEN.getScoreMin()).isEqualTo(40);
assertThat(NiveauRisqueKyc.MOYEN.getScoreMax()).isEqualTo(69);
assertThat(NiveauRisqueKyc.ELEVE.getScoreMin()).isEqualTo(70);
assertThat(NiveauRisqueKyc.ELEVE.getScoreMax()).isEqualTo(89);
assertThat(NiveauRisqueKyc.CRITIQUE.getScoreMin()).isEqualTo(90);
assertThat(NiveauRisqueKyc.CRITIQUE.getScoreMax()).isEqualTo(100);
}
// -------------------------------------------------------------------------
// Enum coverage: TypePieceIdentite
// -------------------------------------------------------------------------
@Test
@DisplayName("all TypePieceIdentite values are assignable")
void typePieceIdentite_allValues() {
KycDossier dossier = new KycDossier();
for (TypePieceIdentite type : TypePieceIdentite.values()) {
dossier.setTypePiece(type);
assertThat(dossier.getTypePiece()).isEqualTo(type);
}
assertThat(TypePieceIdentite.CNI.getLibelle()).isEqualTo("Carte Nationale d'Identité");
assertThat(TypePieceIdentite.PASSEPORT.getLibelle()).isEqualTo("Passeport");
assertThat(TypePieceIdentite.TITRE_SEJOUR.getLibelle()).isEqualTo("Titre de séjour");
assertThat(TypePieceIdentite.CARTE_CONSULAIRE.getLibelle()).isEqualTo("Carte consulaire");
assertThat(TypePieceIdentite.PERMIS_CONDUIRE.getLibelle()).isEqualTo("Permis de conduire");
assertThat(TypePieceIdentite.AUTRE.getLibelle()).isEqualTo("Autre pièce officielle");
}
// -------------------------------------------------------------------------
// UUID id (inherited from BaseEntity)
// -------------------------------------------------------------------------
@Test
@DisplayName("UUID id field is settable and gettable")
void uuidId_settableAndGettable() {
UUID id = UUID.randomUUID();
KycDossier dossier = new KycDossier();
dossier.setId(id);
assertThat(dossier.getId()).isEqualTo(id);
}
// -------------------------------------------------------------------------
// equals / hashCode (Lombok @EqualsAndHashCode(callSuper=true))
// -------------------------------------------------------------------------
@Test
@DisplayName("two instances with same id are equal")
void equals_sameId_areEqual() {
UUID id = UUID.randomUUID();
KycDossier a = new KycDossier();
a.setId(id);
a.setNumeroPiece("P1");
KycDossier b = new KycDossier();
b.setId(id);
b.setNumeroPiece("P1");
assertThat(a).isEqualTo(b);
assertThat(a.hashCode()).isEqualTo(b.hashCode());
}
@Test
@DisplayName("two instances with different ids are not equal")
void equals_differentId_areNotEqual() {
KycDossier a = new KycDossier();
a.setId(UUID.randomUUID());
KycDossier b = new KycDossier();
b.setId(UUID.randomUUID());
assertThat(a).isNotEqualTo(b);
}
}

View File

@@ -0,0 +1,170 @@
package dev.lions.unionflow.server.entity;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("MembreSuivi entity")
class MembreSuiviTest {
// -------------------------------------------------------------------------
// No-args constructor
// -------------------------------------------------------------------------
@Test
@DisplayName("no-args constructor creates instance with null UUIDs")
void noArgsConstructor_createsInstanceWithNullFields() {
MembreSuivi suivi = new MembreSuivi();
assertThat(suivi).isNotNull();
assertThat(suivi.getFollowerUtilisateurId()).isNull();
assertThat(suivi.getSuiviUtilisateurId()).isNull();
}
// -------------------------------------------------------------------------
// All-args constructor
// -------------------------------------------------------------------------
@Test
@DisplayName("all-args constructor sets all fields")
void allArgsConstructor_setsAllFields() {
UUID followerId = UUID.randomUUID();
UUID suiviId = UUID.randomUUID();
MembreSuivi suivi = new MembreSuivi(followerId, suiviId);
assertThat(suivi.getFollowerUtilisateurId()).isEqualTo(followerId);
assertThat(suivi.getSuiviUtilisateurId()).isEqualTo(suiviId);
}
// -------------------------------------------------------------------------
// Builder
// -------------------------------------------------------------------------
@Nested
@DisplayName("Builder")
class BuilderTests {
@Test
@DisplayName("builder sets followerUtilisateurId")
void builder_setsFollowerUtilisateurId() {
UUID id = UUID.randomUUID();
MembreSuivi suivi = MembreSuivi.builder()
.followerUtilisateurId(id)
.build();
assertThat(suivi.getFollowerUtilisateurId()).isEqualTo(id);
}
@Test
@DisplayName("builder sets suiviUtilisateurId")
void builder_setsSuiviUtilisateurId() {
UUID id = UUID.randomUUID();
MembreSuivi suivi = MembreSuivi.builder()
.suiviUtilisateurId(id)
.build();
assertThat(suivi.getSuiviUtilisateurId()).isEqualTo(id);
}
@Test
@DisplayName("builder sets both UUID fields")
void builder_setsBothUuidFields() {
UUID followerId = UUID.fromString("11111111-1111-1111-1111-111111111111");
UUID suiviId = UUID.fromString("22222222-2222-2222-2222-222222222222");
MembreSuivi suivi = MembreSuivi.builder()
.followerUtilisateurId(followerId)
.suiviUtilisateurId(suiviId)
.build();
assertThat(suivi.getFollowerUtilisateurId()).isEqualTo(followerId);
assertThat(suivi.getSuiviUtilisateurId()).isEqualTo(suiviId);
}
}
// -------------------------------------------------------------------------
// Getters / Setters
// -------------------------------------------------------------------------
@Nested
@DisplayName("Getters and Setters")
class GettersSettersTests {
@Test
@DisplayName("setFollowerUtilisateurId / getFollowerUtilisateurId round-trips")
void followerUtilisateurId_roundTrips() {
UUID id = UUID.randomUUID();
MembreSuivi suivi = new MembreSuivi();
suivi.setFollowerUtilisateurId(id);
assertThat(suivi.getFollowerUtilisateurId()).isEqualTo(id);
}
@Test
@DisplayName("setSuiviUtilisateurId / getSuiviUtilisateurId round-trips")
void suiviUtilisateurId_roundTrips() {
UUID id = UUID.randomUUID();
MembreSuivi suivi = new MembreSuivi();
suivi.setSuiviUtilisateurId(id);
assertThat(suivi.getSuiviUtilisateurId()).isEqualTo(id);
}
@Test
@DisplayName("follower and suivi IDs can be different")
void followerAndSuivi_canBeDifferent() {
UUID followerId = UUID.randomUUID();
UUID suiviId = UUID.randomUUID();
MembreSuivi suivi = new MembreSuivi();
suivi.setFollowerUtilisateurId(followerId);
suivi.setSuiviUtilisateurId(suiviId);
assertThat(suivi.getFollowerUtilisateurId()).isNotEqualTo(suivi.getSuiviUtilisateurId());
}
}
// -------------------------------------------------------------------------
// UUID id (inherited from BaseEntity)
// -------------------------------------------------------------------------
@Test
@DisplayName("UUID id field is settable and gettable")
void uuidId_settableAndGettable() {
UUID id = UUID.randomUUID();
MembreSuivi suivi = new MembreSuivi();
suivi.setId(id);
assertThat(suivi.getId()).isEqualTo(id);
}
// -------------------------------------------------------------------------
// equals / hashCode
// -------------------------------------------------------------------------
@Test
@DisplayName("two instances with the same field values are equal")
void equals_sameValues_areEqual() {
UUID followerId = UUID.randomUUID();
UUID suiviId = UUID.randomUUID();
MembreSuivi a = new MembreSuivi(followerId, suiviId);
MembreSuivi b = new MembreSuivi(followerId, suiviId);
assertThat(a).isEqualTo(b);
assertThat(a.hashCode()).isEqualTo(b.hashCode());
}
@Test
@DisplayName("two instances with different follower IDs are not equal")
void equals_differentFollowerId_notEqual() {
UUID suiviId = UUID.randomUUID();
MembreSuivi a = new MembreSuivi(UUID.randomUUID(), suiviId);
MembreSuivi b = new MembreSuivi(UUID.randomUUID(), suiviId);
assertThat(a).isNotEqualTo(b);
}
@Test
@DisplayName("toString contains field values")
void toString_containsFields() {
UUID followerId = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
MembreSuivi suivi = MembreSuivi.builder().followerUtilisateurId(followerId).build();
assertThat(suivi.toString()).contains("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
}
}

View File

@@ -0,0 +1,214 @@
package dev.lions.unionflow.server.entity;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("PaiementObjet entity")
class PaiementObjetTest {
// -------------------------------------------------------------------------
// No-args constructor
// -------------------------------------------------------------------------
@Test
@DisplayName("no-args constructor creates instance with null fields")
void noArgsConstructor_createsInstanceWithNullFields() {
PaiementObjet po = new PaiementObjet();
assertThat(po).isNotNull();
assertThat(po.getPaiement()).isNull();
assertThat(po.getTypeObjetCible()).isNull();
assertThat(po.getObjetCibleId()).isNull();
assertThat(po.getMontantApplique()).isNull();
assertThat(po.getDateApplication()).isNull();
assertThat(po.getCommentaire()).isNull();
}
// -------------------------------------------------------------------------
// Builder
// -------------------------------------------------------------------------
@Nested
@DisplayName("Builder")
class BuilderTests {
@Test
@DisplayName("builder sets all fields")
void builder_setsAllFields() {
Paiement paiement = new Paiement();
UUID objetId = UUID.randomUUID();
LocalDateTime now = LocalDateTime.now();
PaiementObjet po = PaiementObjet.builder()
.paiement(paiement)
.typeObjetCible("COTISATION")
.objetCibleId(objetId)
.montantApplique(BigDecimal.valueOf(15000))
.dateApplication(now)
.commentaire("Application cotisation mensuelle")
.build();
assertThat(po.getPaiement()).isSameAs(paiement);
assertThat(po.getTypeObjetCible()).isEqualTo("COTISATION");
assertThat(po.getObjetCibleId()).isEqualTo(objetId);
assertThat(po.getMontantApplique()).isEqualByComparingTo("15000");
assertThat(po.getDateApplication()).isEqualTo(now);
assertThat(po.getCommentaire()).isEqualTo("Application cotisation mensuelle");
}
@Test
@DisplayName("builder produces distinct instances")
void builder_producesDistinctInstances() {
PaiementObjet po1 = PaiementObjet.builder().typeObjetCible("ADHESION").build();
PaiementObjet po2 = PaiementObjet.builder().typeObjetCible("EVENEMENT").build();
assertThat(po1).isNotSameAs(po2);
assertThat(po1.getTypeObjetCible()).isNotEqualTo(po2.getTypeObjetCible());
}
}
// -------------------------------------------------------------------------
// All-args constructor via setters
// -------------------------------------------------------------------------
@Test
@DisplayName("all fields set via setters are accessible")
void allFields_setViaSetters() {
Paiement paiement = new Paiement();
UUID objetId = UUID.randomUUID();
LocalDateTime dt = LocalDateTime.of(2026, 3, 15, 9, 0);
PaiementObjet po = new PaiementObjet();
po.setPaiement(paiement);
po.setTypeObjetCible("AIDE");
po.setObjetCibleId(objetId);
po.setMontantApplique(BigDecimal.valueOf(5000, 2));
po.setDateApplication(dt);
po.setCommentaire("Aide urgence");
assertThat(po.getPaiement()).isSameAs(paiement);
assertThat(po.getTypeObjetCible()).isEqualTo("AIDE");
assertThat(po.getObjetCibleId()).isEqualTo(objetId);
assertThat(po.getMontantApplique()).isEqualByComparingTo(BigDecimal.valueOf(5000, 2));
assertThat(po.getDateApplication()).isEqualTo(dt);
assertThat(po.getCommentaire()).isEqualTo("Aide urgence");
}
// -------------------------------------------------------------------------
// Getters / Setters individual
// -------------------------------------------------------------------------
@Nested
@DisplayName("Getters and Setters")
class GettersSettersTests {
@Test
@DisplayName("setPaiement / getPaiement round-trips")
void paiement_roundTrips() {
Paiement paiement = new Paiement();
PaiementObjet po = new PaiementObjet();
po.setPaiement(paiement);
assertThat(po.getPaiement()).isSameAs(paiement);
}
@Test
@DisplayName("setTypeObjetCible / getTypeObjetCible round-trips")
void typeObjetCible_roundTrips() {
PaiementObjet po = new PaiementObjet();
po.setTypeObjetCible("EVENEMENT");
assertThat(po.getTypeObjetCible()).isEqualTo("EVENEMENT");
}
@Test
@DisplayName("setObjetCibleId / getObjetCibleId round-trips")
void objetCibleId_roundTrips() {
UUID id = UUID.randomUUID();
PaiementObjet po = new PaiementObjet();
po.setObjetCibleId(id);
assertThat(po.getObjetCibleId()).isEqualTo(id);
}
@Test
@DisplayName("setMontantApplique / getMontantApplique round-trips")
void montantApplique_roundTrips() {
BigDecimal montant = new BigDecimal("12500.00");
PaiementObjet po = new PaiementObjet();
po.setMontantApplique(montant);
assertThat(po.getMontantApplique()).isEqualByComparingTo(montant);
}
@Test
@DisplayName("setDateApplication / getDateApplication round-trips")
void dateApplication_roundTrips() {
LocalDateTime dt = LocalDateTime.of(2026, 1, 1, 0, 0);
PaiementObjet po = new PaiementObjet();
po.setDateApplication(dt);
assertThat(po.getDateApplication()).isEqualTo(dt);
}
@Test
@DisplayName("setCommentaire / getCommentaire round-trips")
void commentaire_roundTrips() {
PaiementObjet po = new PaiementObjet();
po.setCommentaire("Détails supplémentaires");
assertThat(po.getCommentaire()).isEqualTo("Détails supplémentaires");
}
}
// -------------------------------------------------------------------------
// Various typeObjetCible values (polymorphic usage)
// -------------------------------------------------------------------------
@Test
@DisplayName("typeObjetCible accepts all expected polymorphic types")
void typeObjetCible_acceptsPolymorphicTypes() {
String[] types = {"COTISATION", "ADHESION", "EVENEMENT", "AIDE"};
for (String type : types) {
PaiementObjet po = PaiementObjet.builder().typeObjetCible(type).build();
assertThat(po.getTypeObjetCible()).isEqualTo(type);
}
}
// -------------------------------------------------------------------------
// UUID id (inherited from BaseEntity)
// -------------------------------------------------------------------------
@Test
@DisplayName("UUID id field is settable and gettable")
void uuidId_settableAndGettable() {
UUID id = UUID.randomUUID();
PaiementObjet po = new PaiementObjet();
po.setId(id);
assertThat(po.getId()).isEqualTo(id);
}
// -------------------------------------------------------------------------
// equals / hashCode
// -------------------------------------------------------------------------
@Test
@DisplayName("two instances with the same UUID id are equal")
void equals_sameId_areEqual() {
UUID id = UUID.randomUUID();
PaiementObjet a = new PaiementObjet();
a.setId(id);
a.setTypeObjetCible("COTISATION");
PaiementObjet b = new PaiementObjet();
b.setId(id);
b.setTypeObjetCible("COTISATION");
assertThat(a).isEqualTo(b);
assertThat(a.hashCode()).isEqualTo(b.hashCode());
}
@Test
@DisplayName("two instances with different ids are not equal")
void equals_differentIds_notEqual() {
PaiementObjet a = new PaiementObjet();
a.setId(UUID.randomUUID());
PaiementObjet b = new PaiementObjet();
b.setId(UUID.randomUUID());
assertThat(a).isNotEqualTo(b);
}
}

View File

@@ -0,0 +1,352 @@
package dev.lions.unionflow.server.entity;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("Paiement entity")
class PaiementTest {
// -------------------------------------------------------------------------
// No-args constructor
// -------------------------------------------------------------------------
@Test
@DisplayName("no-args constructor creates instance with null/default fields")
void noArgsConstructor_createsInstance() {
Paiement paiement = new Paiement();
assertThat(paiement).isNotNull();
assertThat(paiement.getNumeroReference()).isNull();
assertThat(paiement.getMontant()).isNull();
assertThat(paiement.getCodeDevise()).isNull();
assertThat(paiement.getMethodePaiement()).isNull();
// statutPaiement default is only set via @Builder.Default — not through no-args
assertThat(paiement.getMembre()).isNull();
}
// -------------------------------------------------------------------------
// Builder
// -------------------------------------------------------------------------
@Nested
@DisplayName("Builder")
class BuilderTests {
@Test
@DisplayName("builder default statutPaiement is EN_ATTENTE")
void builder_defaultStatutPaiementIsEnAttente() {
Paiement paiement = Paiement.builder()
.numeroReference("PAY-2026-000001")
.montant(BigDecimal.valueOf(5000))
.codeDevise("XOF")
.methodePaiement("WAVE")
.build();
assertThat(paiement.getStatutPaiement()).isEqualTo("EN_ATTENTE");
}
@Test
@DisplayName("builder default paiementsObjets is empty list")
void builder_defaultPaiementsObjetsIsEmptyList() {
Paiement paiement = Paiement.builder().build();
assertThat(paiement.getPaiementsObjets()).isNotNull().isEmpty();
}
@Test
@DisplayName("builder sets all explicit fields")
void builder_setsAllExplicitFields() {
LocalDateTime now = LocalDateTime.now();
Membre membre = new Membre();
Paiement paiement = Paiement.builder()
.numeroReference("PAY-2026-000042")
.montant(BigDecimal.valueOf(10000, 2))
.codeDevise("XOF")
.methodePaiement("ORANGE_MONEY")
.statutPaiement("VALIDE")
.datePaiement(now)
.dateValidation(now.plusMinutes(5))
.validateur("admin@unionflow.com")
.referenceExterne("EXT-REF-001")
.urlPreuve("https://example.com/preuve.jpg")
.commentaire("Paiement cotisation mars 2026")
.ipAddress("192.168.1.1")
.userAgent("Mozilla/5.0")
.membre(membre)
.paiementsObjets(new ArrayList<>())
.build();
assertThat(paiement.getNumeroReference()).isEqualTo("PAY-2026-000042");
assertThat(paiement.getMontant()).isEqualByComparingTo(BigDecimal.valueOf(10000, 2));
assertThat(paiement.getCodeDevise()).isEqualTo("XOF");
assertThat(paiement.getMethodePaiement()).isEqualTo("ORANGE_MONEY");
assertThat(paiement.getStatutPaiement()).isEqualTo("VALIDE");
assertThat(paiement.getDatePaiement()).isEqualTo(now);
assertThat(paiement.getDateValidation()).isEqualTo(now.plusMinutes(5));
assertThat(paiement.getValidateur()).isEqualTo("admin@unionflow.com");
assertThat(paiement.getReferenceExterne()).isEqualTo("EXT-REF-001");
assertThat(paiement.getUrlPreuve()).isEqualTo("https://example.com/preuve.jpg");
assertThat(paiement.getCommentaire()).isEqualTo("Paiement cotisation mars 2026");
assertThat(paiement.getIpAddress()).isEqualTo("192.168.1.1");
assertThat(paiement.getUserAgent()).isEqualTo("Mozilla/5.0");
assertThat(paiement.getMembre()).isSameAs(membre);
}
}
// -------------------------------------------------------------------------
// Getters / Setters
// -------------------------------------------------------------------------
@Nested
@DisplayName("Getters and Setters")
class GettersSettersTests {
@Test
@DisplayName("setNumeroReference / getNumeroReference round-trips")
void numeroReference_roundTrips() {
Paiement p = new Paiement();
p.setNumeroReference("PAY-TEST-001");
assertThat(p.getNumeroReference()).isEqualTo("PAY-TEST-001");
}
@Test
@DisplayName("setMontant / getMontant round-trips")
void montant_roundTrips() {
Paiement p = new Paiement();
p.setMontant(BigDecimal.valueOf(25000));
assertThat(p.getMontant()).isEqualByComparingTo("25000");
}
@Test
@DisplayName("setCodeDevise / getCodeDevise round-trips")
void codeDevise_roundTrips() {
Paiement p = new Paiement();
p.setCodeDevise("EUR");
assertThat(p.getCodeDevise()).isEqualTo("EUR");
}
@Test
@DisplayName("setMethodePaiement / getMethodePaiement round-trips")
void methodePaiement_roundTrips() {
Paiement p = new Paiement();
p.setMethodePaiement("VIREMENT");
assertThat(p.getMethodePaiement()).isEqualTo("VIREMENT");
}
@Test
@DisplayName("setStatutPaiement / getStatutPaiement round-trips")
void statutPaiement_roundTrips() {
Paiement p = new Paiement();
p.setStatutPaiement("ANNULE");
assertThat(p.getStatutPaiement()).isEqualTo("ANNULE");
}
@Test
@DisplayName("setDatePaiement / getDatePaiement round-trips")
void datePaiement_roundTrips() {
LocalDateTime dt = LocalDateTime.of(2026, 4, 1, 10, 0);
Paiement p = new Paiement();
p.setDatePaiement(dt);
assertThat(p.getDatePaiement()).isEqualTo(dt);
}
@Test
@DisplayName("setDateValidation / getDateValidation round-trips")
void dateValidation_roundTrips() {
LocalDateTime dt = LocalDateTime.of(2026, 4, 1, 11, 0);
Paiement p = new Paiement();
p.setDateValidation(dt);
assertThat(p.getDateValidation()).isEqualTo(dt);
}
@Test
@DisplayName("setValidateur / getValidateur round-trips")
void validateur_roundTrips() {
Paiement p = new Paiement();
p.setValidateur("tresorier@example.com");
assertThat(p.getValidateur()).isEqualTo("tresorier@example.com");
}
@Test
@DisplayName("setReferenceExterne / getReferenceExterne round-trips")
void referenceExterne_roundTrips() {
Paiement p = new Paiement();
p.setReferenceExterne("TXN-98765");
assertThat(p.getReferenceExterne()).isEqualTo("TXN-98765");
}
@Test
@DisplayName("setUrlPreuve / getUrlPreuve round-trips")
void urlPreuve_roundTrips() {
Paiement p = new Paiement();
p.setUrlPreuve("https://storage.example.com/preuves/p1.jpg");
assertThat(p.getUrlPreuve()).isEqualTo("https://storage.example.com/preuves/p1.jpg");
}
@Test
@DisplayName("setCommentaire / getCommentaire round-trips")
void commentaire_roundTrips() {
Paiement p = new Paiement();
p.setCommentaire("Commentaire test");
assertThat(p.getCommentaire()).isEqualTo("Commentaire test");
}
@Test
@DisplayName("setIpAddress / getIpAddress round-trips")
void ipAddress_roundTrips() {
Paiement p = new Paiement();
p.setIpAddress("10.0.0.1");
assertThat(p.getIpAddress()).isEqualTo("10.0.0.1");
}
@Test
@DisplayName("setUserAgent / getUserAgent round-trips")
void userAgent_roundTrips() {
Paiement p = new Paiement();
p.setUserAgent("TestAgent/1.0");
assertThat(p.getUserAgent()).isEqualTo("TestAgent/1.0");
}
@Test
@DisplayName("setMembre / getMembre round-trips")
void membre_roundTrips() {
Membre membre = new Membre();
Paiement p = new Paiement();
p.setMembre(membre);
assertThat(p.getMembre()).isSameAs(membre);
}
@Test
@DisplayName("setPaiementsObjets / getPaiementsObjets round-trips")
void paiementsObjets_roundTrips() {
List<PaiementObjet> objets = new ArrayList<>();
Paiement p = new Paiement();
p.setPaiementsObjets(objets);
assertThat(p.getPaiementsObjets()).isSameAs(objets);
}
@Test
@DisplayName("setTransactionWave / getTransactionWave round-trips")
void transactionWave_roundTrips() {
TransactionWave tw = new TransactionWave();
Paiement p = new Paiement();
p.setTransactionWave(tw);
assertThat(p.getTransactionWave()).isSameAs(tw);
}
}
// -------------------------------------------------------------------------
// UUID id (inherited from BaseEntity)
// -------------------------------------------------------------------------
@Test
@DisplayName("UUID id field is settable and gettable")
void uuidId_settableAndGettable() {
UUID id = UUID.randomUUID();
Paiement p = new Paiement();
p.setId(id);
assertThat(p.getId()).isEqualTo(id);
}
// -------------------------------------------------------------------------
// Business method: isValide()
// -------------------------------------------------------------------------
@Nested
@DisplayName("isValide()")
class IsValideTests {
@Test
@DisplayName("returns true when statutPaiement is VALIDE")
void isValide_returnsTrue_whenValide() {
Paiement p = Paiement.builder().statutPaiement("VALIDE").build();
assertThat(p.isValide()).isTrue();
}
@Test
@DisplayName("returns false when statutPaiement is EN_ATTENTE")
void isValide_returnsFalse_whenEnAttente() {
Paiement p = Paiement.builder().build(); // default EN_ATTENTE
assertThat(p.isValide()).isFalse();
}
@Test
@DisplayName("returns false when statutPaiement is ANNULE")
void isValide_returnsFalse_whenAnnule() {
Paiement p = Paiement.builder().statutPaiement("ANNULE").build();
assertThat(p.isValide()).isFalse();
}
@Test
@DisplayName("returns false when statutPaiement is REJETE")
void isValide_returnsFalse_whenRejete() {
Paiement p = Paiement.builder().statutPaiement("REJETE").build();
assertThat(p.isValide()).isFalse();
}
}
// -------------------------------------------------------------------------
// Business method: peutEtreModifie()
// -------------------------------------------------------------------------
@Nested
@DisplayName("peutEtreModifie()")
class PeutEtreModifieTests {
@Test
@DisplayName("returns true when statutPaiement is EN_ATTENTE")
void peutEtreModifie_returnsTrue_whenEnAttente() {
Paiement p = Paiement.builder().build(); // default EN_ATTENTE
assertThat(p.peutEtreModifie()).isTrue();
}
@Test
@DisplayName("returns false when statutPaiement is VALIDE")
void peutEtreModifie_returnsFalse_whenValide() {
Paiement p = Paiement.builder().statutPaiement("VALIDE").build();
assertThat(p.peutEtreModifie()).isFalse();
}
@Test
@DisplayName("returns false when statutPaiement is ANNULE")
void peutEtreModifie_returnsFalse_whenAnnule() {
Paiement p = Paiement.builder().statutPaiement("ANNULE").build();
assertThat(p.peutEtreModifie()).isFalse();
}
@Test
@DisplayName("returns true when statutPaiement is REJETE")
void peutEtreModifie_returnsTrue_whenRejete() {
Paiement p = Paiement.builder().statutPaiement("REJETE").build();
assertThat(p.peutEtreModifie()).isTrue();
}
}
// -------------------------------------------------------------------------
// Static factory: genererNumeroReference()
// -------------------------------------------------------------------------
@Test
@DisplayName("genererNumeroReference returns a non-null string matching PAY-YYYY-<digits> pattern")
void genererNumeroReference_returnsValidString() {
String ref = Paiement.genererNumeroReference();
assertThat(ref).isNotNull();
assertThat(ref).startsWith("PAY-");
assertThat(ref).matches("PAY-\\d{4}-\\d{12}");
}
@Test
@DisplayName("genererNumeroReference generates unique values on successive calls")
void genererNumeroReference_isUnique() throws InterruptedException {
String ref1 = Paiement.genererNumeroReference();
Thread.sleep(1); // ensure millis differ
String ref2 = Paiement.genererNumeroReference();
// They may collide on the same millisecond modulo, so just verify format
assertThat(ref1).matches("PAY-\\d{4}-\\d{12}");
assertThat(ref2).matches("PAY-\\d{4}-\\d{12}");
}
}

View File

@@ -0,0 +1,201 @@
package dev.lions.unionflow.server.entity;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("ParametresLcbFt entity")
class ParametresLcbFtTest {
// -------------------------------------------------------------------------
// No-args constructor
// -------------------------------------------------------------------------
@Test
@DisplayName("no-args constructor creates instance with null fields")
void noArgsConstructor_createsInstanceWithNullFields() {
ParametresLcbFt params = new ParametresLcbFt();
assertThat(params).isNotNull();
assertThat(params.getOrganisation()).isNull();
assertThat(params.getCodeDevise()).isNull();
assertThat(params.getMontantSeuilJustification()).isNull();
assertThat(params.getMontantSeuilValidationManuelle()).isNull();
}
// -------------------------------------------------------------------------
// All-args constructor
// -------------------------------------------------------------------------
@Test
@DisplayName("all-args constructor sets all fields")
void allArgsConstructor_setsAllFields() {
Organisation org = new Organisation();
BigDecimal seuilJustif = new BigDecimal("500000.0000");
BigDecimal seuilValidation = new BigDecimal("2000000.0000");
ParametresLcbFt params = new ParametresLcbFt(org, "XOF", seuilJustif, seuilValidation);
assertThat(params.getOrganisation()).isSameAs(org);
assertThat(params.getCodeDevise()).isEqualTo("XOF");
assertThat(params.getMontantSeuilJustification()).isEqualByComparingTo(seuilJustif);
assertThat(params.getMontantSeuilValidationManuelle()).isEqualByComparingTo(seuilValidation);
}
// -------------------------------------------------------------------------
// Builder
// -------------------------------------------------------------------------
@Nested
@DisplayName("Builder")
class BuilderTests {
@Test
@DisplayName("builder sets organisation and thresholds")
void builder_setsAllFields() {
Organisation org = new Organisation();
ParametresLcbFt params = ParametresLcbFt.builder()
.organisation(org)
.codeDevise("EUR")
.montantSeuilJustification(new BigDecimal("10000.0000"))
.montantSeuilValidationManuelle(new BigDecimal("50000.0000"))
.build();
assertThat(params.getOrganisation()).isSameAs(org);
assertThat(params.getCodeDevise()).isEqualTo("EUR");
assertThat(params.getMontantSeuilJustification()).isEqualByComparingTo("10000.0000");
assertThat(params.getMontantSeuilValidationManuelle()).isEqualByComparingTo("50000.0000");
}
@Test
@DisplayName("builder with null organisation represents global parameters")
void builder_nullOrganisationRepresentsGlobal() {
ParametresLcbFt params = ParametresLcbFt.builder()
.organisation(null)
.codeDevise("XOF")
.montantSeuilJustification(new BigDecimal("1000000.0000"))
.build();
assertThat(params.getOrganisation()).isNull();
assertThat(params.getCodeDevise()).isEqualTo("XOF");
}
@Test
@DisplayName("builder allows null montantSeuilValidationManuelle (optional field)")
void builder_nullSeuilValidationManuelle_isAllowed() {
ParametresLcbFt params = ParametresLcbFt.builder()
.codeDevise("XOF")
.montantSeuilJustification(BigDecimal.valueOf(500000))
.montantSeuilValidationManuelle(null)
.build();
assertThat(params.getMontantSeuilValidationManuelle()).isNull();
}
}
// -------------------------------------------------------------------------
// Getters / Setters
// -------------------------------------------------------------------------
@Nested
@DisplayName("Getters and Setters")
class GettersSettersTests {
@Test
@DisplayName("setOrganisation / getOrganisation round-trips")
void organisation_roundTrips() {
Organisation org = new Organisation();
ParametresLcbFt params = new ParametresLcbFt();
params.setOrganisation(org);
assertThat(params.getOrganisation()).isSameAs(org);
}
@Test
@DisplayName("setCodeDevise / getCodeDevise round-trips")
void codeDevise_roundTrips() {
ParametresLcbFt params = new ParametresLcbFt();
params.setCodeDevise("GNF");
assertThat(params.getCodeDevise()).isEqualTo("GNF");
}
@Test
@DisplayName("setMontantSeuilJustification / getMontantSeuilJustification round-trips")
void montantSeuilJustification_roundTrips() {
BigDecimal seuil = new BigDecimal("750000.0000");
ParametresLcbFt params = new ParametresLcbFt();
params.setMontantSeuilJustification(seuil);
assertThat(params.getMontantSeuilJustification()).isEqualByComparingTo(seuil);
}
@Test
@DisplayName("setMontantSeuilValidationManuelle / getMontantSeuilValidationManuelle round-trips")
void montantSeuilValidationManuelle_roundTrips() {
BigDecimal seuil = new BigDecimal("3000000.0000");
ParametresLcbFt params = new ParametresLcbFt();
params.setMontantSeuilValidationManuelle(seuil);
assertThat(params.getMontantSeuilValidationManuelle()).isEqualByComparingTo(seuil);
}
@Test
@DisplayName("setOrganisation to null (global params) is allowed")
void organisation_canBeNull() {
ParametresLcbFt params = new ParametresLcbFt();
params.setOrganisation(null);
assertThat(params.getOrganisation()).isNull();
}
}
// -------------------------------------------------------------------------
// UUID id (inherited from BaseEntity)
// -------------------------------------------------------------------------
@Test
@DisplayName("UUID id field is settable and gettable")
void uuidId_settableAndGettable() {
UUID id = UUID.randomUUID();
ParametresLcbFt params = new ParametresLcbFt();
params.setId(id);
assertThat(params.getId()).isEqualTo(id);
}
// -------------------------------------------------------------------------
// equals / hashCode
// -------------------------------------------------------------------------
@Test
@DisplayName("two instances with same id are equal")
void equals_sameId_areEqual() {
UUID id = UUID.randomUUID();
ParametresLcbFt a = new ParametresLcbFt();
a.setId(id);
a.setCodeDevise("XOF");
ParametresLcbFt b = new ParametresLcbFt();
b.setId(id);
b.setCodeDevise("XOF");
assertThat(a).isEqualTo(b);
assertThat(a.hashCode()).isEqualTo(b.hashCode());
}
@Test
@DisplayName("two instances with different ids are not equal")
void equals_differentIds_notEqual() {
ParametresLcbFt a = new ParametresLcbFt();
a.setId(UUID.randomUUID());
ParametresLcbFt b = new ParametresLcbFt();
b.setId(UUID.randomUUID());
assertThat(a).isNotEqualTo(b);
}
// -------------------------------------------------------------------------
// toString
// -------------------------------------------------------------------------
@Test
@DisplayName("toString contains codeDevise")
void toString_containsCodeDevise() {
ParametresLcbFt params = new ParametresLcbFt();
params.setCodeDevise("XOF");
assertThat(params.toString()).contains("XOF");
}
}

View File

@@ -0,0 +1,240 @@
package dev.lions.unionflow.server.entity;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("SystemAlert entity")
class SystemAlertTest {
// -------------------------------------------------------------------------
// No-args constructor
// -------------------------------------------------------------------------
@Test
@DisplayName("no-args constructor creates instance with default acknowledged=false")
void noArgsConstructor_defaultAcknowledgedFalse() {
SystemAlert alert = new SystemAlert();
assertThat(alert).isNotNull();
// Field initialiser on the declaration sets it to false
assertThat(alert.getAcknowledged()).isFalse();
assertThat(alert.getLevel()).isNull();
assertThat(alert.getTitle()).isNull();
assertThat(alert.getMessage()).isNull();
assertThat(alert.getTimestamp()).isNull();
assertThat(alert.getAcknowledgedBy()).isNull();
assertThat(alert.getAcknowledgedAt()).isNull();
assertThat(alert.getSource()).isNull();
assertThat(alert.getAlertType()).isNull();
assertThat(alert.getCurrentValue()).isNull();
assertThat(alert.getThresholdValue()).isNull();
assertThat(alert.getUnit()).isNull();
assertThat(alert.getRecommendedActions()).isNull();
}
// -------------------------------------------------------------------------
// Getters / Setters — every field
// -------------------------------------------------------------------------
@Nested
@DisplayName("Getters and Setters")
class GettersSettersTests {
@Test
@DisplayName("setLevel / getLevel round-trips")
void level_roundTrips() {
SystemAlert alert = new SystemAlert();
alert.setLevel("CRITICAL");
assertThat(alert.getLevel()).isEqualTo("CRITICAL");
}
@Test
@DisplayName("setTitle / getTitle round-trips")
void title_roundTrips() {
SystemAlert alert = new SystemAlert();
alert.setTitle("CPU très élevé");
assertThat(alert.getTitle()).isEqualTo("CPU très élevé");
}
@Test
@DisplayName("setMessage / getMessage round-trips")
void message_roundTrips() {
SystemAlert alert = new SystemAlert();
alert.setMessage("Utilisation CPU > 95% depuis 5 minutes");
assertThat(alert.getMessage()).isEqualTo("Utilisation CPU > 95% depuis 5 minutes");
}
@Test
@DisplayName("setTimestamp / getTimestamp round-trips")
void timestamp_roundTrips() {
LocalDateTime ts = LocalDateTime.of(2026, 4, 20, 12, 0, 0);
SystemAlert alert = new SystemAlert();
alert.setTimestamp(ts);
assertThat(alert.getTimestamp()).isEqualTo(ts);
}
@Test
@DisplayName("setAcknowledged true / getAcknowledged round-trips")
void acknowledged_true_roundTrips() {
SystemAlert alert = new SystemAlert();
alert.setAcknowledged(true);
assertThat(alert.getAcknowledged()).isTrue();
}
@Test
@DisplayName("setAcknowledged false / getAcknowledged round-trips")
void acknowledged_false_roundTrips() {
SystemAlert alert = new SystemAlert();
alert.setAcknowledged(false);
assertThat(alert.getAcknowledged()).isFalse();
}
@Test
@DisplayName("setAcknowledgedBy / getAcknowledgedBy round-trips")
void acknowledgedBy_roundTrips() {
SystemAlert alert = new SystemAlert();
alert.setAcknowledgedBy("admin@unionflow.com");
assertThat(alert.getAcknowledgedBy()).isEqualTo("admin@unionflow.com");
}
@Test
@DisplayName("setAcknowledgedAt / getAcknowledgedAt round-trips")
void acknowledgedAt_roundTrips() {
LocalDateTime dt = LocalDateTime.of(2026, 4, 20, 12, 30, 0);
SystemAlert alert = new SystemAlert();
alert.setAcknowledgedAt(dt);
assertThat(alert.getAcknowledgedAt()).isEqualTo(dt);
}
@Test
@DisplayName("setSource / getSource round-trips")
void source_roundTrips() {
SystemAlert alert = new SystemAlert();
alert.setSource("CPU");
assertThat(alert.getSource()).isEqualTo("CPU");
}
@Test
@DisplayName("setAlertType / getAlertType round-trips")
void alertType_roundTrips() {
SystemAlert alert = new SystemAlert();
alert.setAlertType("THRESHOLD");
assertThat(alert.getAlertType()).isEqualTo("THRESHOLD");
}
@Test
@DisplayName("setCurrentValue / getCurrentValue round-trips")
void currentValue_roundTrips() {
SystemAlert alert = new SystemAlert();
alert.setCurrentValue(97.5);
assertThat(alert.getCurrentValue()).isEqualTo(97.5);
}
@Test
@DisplayName("setThresholdValue / getThresholdValue round-trips")
void thresholdValue_roundTrips() {
SystemAlert alert = new SystemAlert();
alert.setThresholdValue(90.0);
assertThat(alert.getThresholdValue()).isEqualTo(90.0);
}
@Test
@DisplayName("setUnit / getUnit round-trips")
void unit_roundTrips() {
SystemAlert alert = new SystemAlert();
alert.setUnit("%");
assertThat(alert.getUnit()).isEqualTo("%");
}
@Test
@DisplayName("setRecommendedActions / getRecommendedActions round-trips")
void recommendedActions_roundTrips() {
SystemAlert alert = new SystemAlert();
alert.setRecommendedActions("Redémarrer le service. Vérifier les logs.");
assertThat(alert.getRecommendedActions()).isEqualTo("Redémarrer le service. Vérifier les logs.");
}
}
// -------------------------------------------------------------------------
// Typical alert level values
// -------------------------------------------------------------------------
@Test
@DisplayName("level accepts all expected severity values")
void level_acceptsAllSeverityValues() {
String[] levels = {"CRITICAL", "ERROR", "WARNING", "INFO"};
SystemAlert alert = new SystemAlert();
for (String level : levels) {
alert.setLevel(level);
assertThat(alert.getLevel()).isEqualTo(level);
}
}
// -------------------------------------------------------------------------
// Typical source values
// -------------------------------------------------------------------------
@Test
@DisplayName("source accepts typical metric sources")
void source_acceptsTypicalSources() {
String[] sources = {"CPU", "MEMORY", "DISK", "DATABASE"};
SystemAlert alert = new SystemAlert();
for (String source : sources) {
alert.setSource(source);
assertThat(alert.getSource()).isEqualTo(source);
}
}
// -------------------------------------------------------------------------
// UUID id (inherited from BaseEntity)
// -------------------------------------------------------------------------
@Test
@DisplayName("UUID id field is settable and gettable")
void uuidId_settableAndGettable() {
UUID id = UUID.randomUUID();
SystemAlert alert = new SystemAlert();
alert.setId(id);
assertThat(alert.getId()).isEqualTo(id);
}
// -------------------------------------------------------------------------
// A complete "acknowledged alert" scenario
// -------------------------------------------------------------------------
@Test
@DisplayName("full acknowledged alert scenario sets all fields coherently")
void fullAcknowledgedAlertScenario() {
LocalDateTime ts = LocalDateTime.of(2026, 4, 20, 8, 0);
LocalDateTime ack = LocalDateTime.of(2026, 4, 20, 8, 15);
SystemAlert alert = new SystemAlert();
alert.setLevel("WARNING");
alert.setTitle("Mémoire élevée");
alert.setMessage("Mémoire utilisée > 80%");
alert.setTimestamp(ts);
alert.setSource("MEMORY");
alert.setAlertType("THRESHOLD");
alert.setCurrentValue(83.2);
alert.setThresholdValue(80.0);
alert.setUnit("%");
alert.setRecommendedActions("Libérer la mémoire ou augmenter la RAM.");
alert.setAcknowledged(true);
alert.setAcknowledgedBy("ops@unionflow.com");
alert.setAcknowledgedAt(ack);
assertThat(alert.getLevel()).isEqualTo("WARNING");
assertThat(alert.getTitle()).isEqualTo("Mémoire élevée");
assertThat(alert.getMessage()).isEqualTo("Mémoire utilisée > 80%");
assertThat(alert.getTimestamp()).isEqualTo(ts);
assertThat(alert.getSource()).isEqualTo("MEMORY");
assertThat(alert.getAlertType()).isEqualTo("THRESHOLD");
assertThat(alert.getCurrentValue()).isEqualTo(83.2);
assertThat(alert.getThresholdValue()).isEqualTo(80.0);
assertThat(alert.getUnit()).isEqualTo("%");
assertThat(alert.getRecommendedActions()).contains("Libérer la mémoire");
assertThat(alert.getAcknowledged()).isTrue();
assertThat(alert.getAcknowledgedBy()).isEqualTo("ops@unionflow.com");
assertThat(alert.getAcknowledgedAt()).isEqualTo(ack);
}
}

View File

@@ -0,0 +1,209 @@
package dev.lions.unionflow.server.entity;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("SystemConfigPersistence entity")
class SystemConfigPersistenceTest {
// -------------------------------------------------------------------------
// No-args constructor
// -------------------------------------------------------------------------
@Test
@DisplayName("no-args constructor creates instance with null fields")
void noArgsConstructor_createsInstanceWithNullFields() {
SystemConfigPersistence config = new SystemConfigPersistence();
assertThat(config).isNotNull();
assertThat(config.getConfigKey()).isNull();
assertThat(config.getConfigValue()).isNull();
}
// -------------------------------------------------------------------------
// All-args constructor
// -------------------------------------------------------------------------
@Test
@DisplayName("all-args constructor sets configKey and configValue")
void allArgsConstructor_setsFields() {
SystemConfigPersistence config = new SystemConfigPersistence("smtp.host", "mail.example.com");
assertThat(config.getConfigKey()).isEqualTo("smtp.host");
assertThat(config.getConfigValue()).isEqualTo("mail.example.com");
}
// -------------------------------------------------------------------------
// Builder
// -------------------------------------------------------------------------
@Nested
@DisplayName("Builder")
class BuilderTests {
@Test
@DisplayName("builder sets configKey")
void builder_setsConfigKey() {
SystemConfigPersistence config = SystemConfigPersistence.builder()
.configKey("app.version")
.build();
assertThat(config.getConfigKey()).isEqualTo("app.version");
}
@Test
@DisplayName("builder sets configValue")
void builder_setsConfigValue() {
SystemConfigPersistence config = SystemConfigPersistence.builder()
.configValue("3.0.0")
.build();
assertThat(config.getConfigValue()).isEqualTo("3.0.0");
}
@Test
@DisplayName("builder sets both configKey and configValue")
void builder_setsBothFields() {
SystemConfigPersistence config = SystemConfigPersistence.builder()
.configKey("feature.kyc.enabled")
.configValue("true")
.build();
assertThat(config.getConfigKey()).isEqualTo("feature.kyc.enabled");
assertThat(config.getConfigValue()).isEqualTo("true");
}
@Test
@DisplayName("builder allows null configValue (TEXT column is nullable)")
void builder_nullConfigValue_isAllowed() {
SystemConfigPersistence config = SystemConfigPersistence.builder()
.configKey("optional.setting")
.configValue(null)
.build();
assertThat(config.getConfigKey()).isEqualTo("optional.setting");
assertThat(config.getConfigValue()).isNull();
}
}
// -------------------------------------------------------------------------
// Getters / Setters
// -------------------------------------------------------------------------
@Nested
@DisplayName("Getters and Setters")
class GettersSettersTests {
@Test
@DisplayName("setConfigKey / getConfigKey round-trips")
void configKey_roundTrips() {
SystemConfigPersistence config = new SystemConfigPersistence();
config.setConfigKey("max.upload.size");
assertThat(config.getConfigKey()).isEqualTo("max.upload.size");
}
@Test
@DisplayName("setConfigValue / getConfigValue round-trips")
void configValue_roundTrips() {
SystemConfigPersistence config = new SystemConfigPersistence();
config.setConfigValue("5242880");
assertThat(config.getConfigValue()).isEqualTo("5242880");
}
@Test
@DisplayName("configValue can store a JSON blob")
void configValue_canStoreJson() {
String json = "{\"enabled\":true,\"maxRetries\":3}";
SystemConfigPersistence config = new SystemConfigPersistence();
config.setConfigValue(json);
assertThat(config.getConfigValue()).isEqualTo(json);
}
@Test
@DisplayName("configValue can store a multiline text")
void configValue_canStoreMultilineText() {
String multiline = "line1\nline2\nline3";
SystemConfigPersistence config = new SystemConfigPersistence();
config.setConfigValue(multiline);
assertThat(config.getConfigValue()).isEqualTo(multiline);
}
}
// -------------------------------------------------------------------------
// UUID id (inherited from BaseEntity)
// -------------------------------------------------------------------------
@Test
@DisplayName("UUID id field is settable and gettable")
void uuidId_settableAndGettable() {
UUID id = UUID.randomUUID();
SystemConfigPersistence config = new SystemConfigPersistence();
config.setId(id);
assertThat(config.getId()).isEqualTo(id);
}
// -------------------------------------------------------------------------
// equals / hashCode (Lombok @EqualsAndHashCode(callSuper=true))
// -------------------------------------------------------------------------
@Test
@DisplayName("two instances with same id are equal")
void equals_sameId_areEqual() {
UUID id = UUID.randomUUID();
SystemConfigPersistence a = new SystemConfigPersistence("key", "val");
a.setId(id);
SystemConfigPersistence b = new SystemConfigPersistence("key", "val");
b.setId(id);
assertThat(a).isEqualTo(b);
assertThat(a.hashCode()).isEqualTo(b.hashCode());
}
@Test
@DisplayName("two instances with different ids are not equal")
void equals_differentIds_notEqual() {
SystemConfigPersistence a = new SystemConfigPersistence("key", "val");
a.setId(UUID.randomUUID());
SystemConfigPersistence b = new SystemConfigPersistence("key", "val");
b.setId(UUID.randomUUID());
assertThat(a).isNotEqualTo(b);
}
// -------------------------------------------------------------------------
// toString
// -------------------------------------------------------------------------
@Test
@DisplayName("toString contains configKey")
void toString_containsConfigKey() {
SystemConfigPersistence config = SystemConfigPersistence.builder()
.configKey("smtp.port")
.configValue("587")
.build();
assertThat(config.toString()).contains("smtp.port");
}
// -------------------------------------------------------------------------
// Typical configuration keys scenario
// -------------------------------------------------------------------------
@Test
@DisplayName("stores various typical configuration key-value pairs")
void typicalConfigurations_storeCorrectly() {
String[][] configs = {
{"smtp.host", "mail.example.com"},
{"smtp.port", "587"},
{"feature.kyc.enabled", "true"},
{"max.upload.size.bytes", "5242880"},
{"default.currency", "XOF"}
};
for (String[] kv : configs) {
SystemConfigPersistence config = SystemConfigPersistence.builder()
.configKey(kv[0])
.configValue(kv[1])
.build();
assertThat(config.getConfigKey()).isEqualTo(kv[0]);
assertThat(config.getConfigValue()).isEqualTo(kv[1]);
}
}
}

View File

@@ -0,0 +1,239 @@
package dev.lions.unionflow.server.entity;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("SystemLog entity")
class SystemLogTest {
// -------------------------------------------------------------------------
// No-args constructor
// -------------------------------------------------------------------------
@Test
@DisplayName("no-args constructor creates instance with all-null fields")
void noArgsConstructor_createsInstanceWithNullFields() {
SystemLog log = new SystemLog();
assertThat(log).isNotNull();
assertThat(log.getLevel()).isNull();
assertThat(log.getSource()).isNull();
assertThat(log.getMessage()).isNull();
assertThat(log.getDetails()).isNull();
assertThat(log.getTimestamp()).isNull();
assertThat(log.getUserId()).isNull();
assertThat(log.getIpAddress()).isNull();
assertThat(log.getSessionId()).isNull();
assertThat(log.getEndpoint()).isNull();
assertThat(log.getHttpStatusCode()).isNull();
}
// -------------------------------------------------------------------------
// Getters / Setters — every field
// -------------------------------------------------------------------------
@Nested
@DisplayName("Getters and Setters")
class GettersSettersTests {
@Test
@DisplayName("setLevel / getLevel round-trips")
void level_roundTrips() {
SystemLog log = new SystemLog();
log.setLevel("ERROR");
assertThat(log.getLevel()).isEqualTo("ERROR");
}
@Test
@DisplayName("setSource / getSource round-trips")
void source_roundTrips() {
SystemLog log = new SystemLog();
log.setSource("Database");
assertThat(log.getSource()).isEqualTo("Database");
}
@Test
@DisplayName("setMessage / getMessage round-trips")
void message_roundTrips() {
SystemLog log = new SystemLog();
log.setMessage("Connection refused on port 5432");
assertThat(log.getMessage()).isEqualTo("Connection refused on port 5432");
}
@Test
@DisplayName("setDetails / getDetails round-trips")
void details_roundTrips() {
String stacktrace = "java.sql.SQLTransientConnectionException\n\tat com.example.Foo.bar(Foo.java:42)";
SystemLog log = new SystemLog();
log.setDetails(stacktrace);
assertThat(log.getDetails()).isEqualTo(stacktrace);
}
@Test
@DisplayName("setTimestamp / getTimestamp round-trips")
void timestamp_roundTrips() {
LocalDateTime ts = LocalDateTime.of(2026, 4, 20, 10, 30, 0);
SystemLog log = new SystemLog();
log.setTimestamp(ts);
assertThat(log.getTimestamp()).isEqualTo(ts);
}
@Test
@DisplayName("setUserId / getUserId round-trips")
void userId_roundTrips() {
SystemLog log = new SystemLog();
log.setUserId("user-abc-123");
assertThat(log.getUserId()).isEqualTo("user-abc-123");
}
@Test
@DisplayName("setIpAddress / getIpAddress round-trips")
void ipAddress_roundTrips() {
SystemLog log = new SystemLog();
log.setIpAddress("10.0.0.1");
assertThat(log.getIpAddress()).isEqualTo("10.0.0.1");
}
@Test
@DisplayName("setSessionId / getSessionId round-trips")
void sessionId_roundTrips() {
SystemLog log = new SystemLog();
log.setSessionId("sess-xyz-789");
assertThat(log.getSessionId()).isEqualTo("sess-xyz-789");
}
@Test
@DisplayName("setEndpoint / getEndpoint round-trips")
void endpoint_roundTrips() {
SystemLog log = new SystemLog();
log.setEndpoint("/api/membres/123/cotisations");
assertThat(log.getEndpoint()).isEqualTo("/api/membres/123/cotisations");
}
@Test
@DisplayName("setHttpStatusCode / getHttpStatusCode round-trips")
void httpStatusCode_roundTrips() {
SystemLog log = new SystemLog();
log.setHttpStatusCode(500);
assertThat(log.getHttpStatusCode()).isEqualTo(500);
}
@Test
@DisplayName("setHttpStatusCode null is allowed (optional field)")
void httpStatusCode_nullIsAllowed() {
SystemLog log = new SystemLog();
log.setHttpStatusCode(null);
assertThat(log.getHttpStatusCode()).isNull();
}
}
// -------------------------------------------------------------------------
// Typical log-level values
// -------------------------------------------------------------------------
@Test
@DisplayName("level accepts all expected log-level values")
void level_acceptsAllExpectedValues() {
String[] levels = {"CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG"};
SystemLog log = new SystemLog();
for (String level : levels) {
log.setLevel(level);
assertThat(log.getLevel()).isEqualTo(level);
}
}
// -------------------------------------------------------------------------
// Typical source values
// -------------------------------------------------------------------------
@Test
@DisplayName("source accepts typical system sources")
void source_acceptsTypicalSources() {
String[] sources = {"Database", "API", "Auth", "System", "Cache"};
SystemLog log = new SystemLog();
for (String source : sources) {
log.setSource(source);
assertThat(log.getSource()).isEqualTo(source);
}
}
// -------------------------------------------------------------------------
// UUID id (inherited from BaseEntity)
// -------------------------------------------------------------------------
@Test
@DisplayName("UUID id field is settable and gettable")
void uuidId_settableAndGettable() {
UUID id = UUID.randomUUID();
SystemLog log = new SystemLog();
log.setId(id);
assertThat(log.getId()).isEqualTo(id);
}
// -------------------------------------------------------------------------
// HTTP status code — boundary values
// -------------------------------------------------------------------------
@Test
@DisplayName("httpStatusCode accepts various HTTP status codes")
void httpStatusCode_acceptsVariousValues() {
int[] codes = {200, 201, 400, 401, 403, 404, 422, 500, 503};
SystemLog log = new SystemLog();
for (int code : codes) {
log.setHttpStatusCode(code);
assertThat(log.getHttpStatusCode()).isEqualTo(code);
}
}
// -------------------------------------------------------------------------
// Full system-error scenario
// -------------------------------------------------------------------------
@Test
@DisplayName("full error log scenario sets all fields coherently")
void fullErrorLogScenario() {
LocalDateTime ts = LocalDateTime.of(2026, 4, 20, 14, 35, 22);
SystemLog log = new SystemLog();
log.setLevel("ERROR");
log.setSource("API");
log.setMessage("NullPointerException in MemberService.findById");
log.setDetails("java.lang.NullPointerException\n\tat dev.lions.MemberService.findById(MemberService.java:88)");
log.setTimestamp(ts);
log.setUserId("user-42");
log.setIpAddress("192.168.0.10");
log.setSessionId("session-abc");
log.setEndpoint("/api/membres/42");
log.setHttpStatusCode(500);
assertThat(log.getLevel()).isEqualTo("ERROR");
assertThat(log.getSource()).isEqualTo("API");
assertThat(log.getMessage()).contains("NullPointerException");
assertThat(log.getDetails()).contains("MemberService.java:88");
assertThat(log.getTimestamp()).isEqualTo(ts);
assertThat(log.getUserId()).isEqualTo("user-42");
assertThat(log.getIpAddress()).isEqualTo("192.168.0.10");
assertThat(log.getSessionId()).isEqualTo("session-abc");
assertThat(log.getEndpoint()).isEqualTo("/api/membres/42");
assertThat(log.getHttpStatusCode()).isEqualTo(500);
}
// -------------------------------------------------------------------------
// Nullable optional fields
// -------------------------------------------------------------------------
@Test
@DisplayName("optional fields can be null independently")
void optionalFields_canBeNullIndependently() {
SystemLog log = new SystemLog();
log.setLevel("INFO");
log.setSource("System");
log.setMessage("Scheduled task completed");
log.setTimestamp(LocalDateTime.now());
// Leave all optional fields as null
assertThat(log.getDetails()).isNull();
assertThat(log.getUserId()).isNull();
assertThat(log.getIpAddress()).isNull();
assertThat(log.getSessionId()).isNull();
assertThat(log.getEndpoint()).isNull();
assertThat(log.getHttpStatusCode()).isNull();
}
}

View File

@@ -0,0 +1,212 @@
package dev.lions.unionflow.server.entity.mutuelle;
import dev.lions.unionflow.server.entity.Organisation;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("ParametresFinanciersMutuelle — entité")
class ParametresFinanciersMutuellTest {
// ─── constructeur no-arg + setters/getters ────────────────────────────────
@Test
@DisplayName("constructeur no-arg : tous les champs par défaut présents")
void noArgConstructor_defaultsPresent() {
ParametresFinanciersMutuelle p = new ParametresFinanciersMutuelle();
assertThat(p.getOrganisation()).isNull();
assertThat(p.getValeurNominaleParDefaut()).isNull(); // Lombok @Builder.Default ne s'active pas avec new
assertThat(p.getProchaineCalculInterets()).isNull();
assertThat(p.getDernierCalculInterets()).isNull();
}
@Test
@DisplayName("setters et getters — valeurs explicites")
void settersGetters() {
ParametresFinanciersMutuelle p = new ParametresFinanciersMutuelle();
Organisation org = new Organisation();
org.setId(UUID.randomUUID());
p.setOrganisation(org);
p.setValeurNominaleParDefaut(new BigDecimal("10000"));
p.setTauxInteretAnnuelEpargne(new BigDecimal("0.05"));
p.setTauxDividendePartsAnnuel(new BigDecimal("0.07"));
p.setPeriodiciteCalcul("TRIMESTRIEL");
p.setSeuilMinEpargneInterets(new BigDecimal("500"));
LocalDate prochaine = LocalDate.of(2026, 7, 1);
p.setProchaineCalculInterets(prochaine);
LocalDate dernier = LocalDate.of(2026, 4, 1);
p.setDernierCalculInterets(dernier);
p.setDernierNbComptesTraites(42);
assertThat(p.getOrganisation()).isSameAs(org);
assertThat(p.getValeurNominaleParDefaut()).isEqualByComparingTo("10000");
assertThat(p.getTauxInteretAnnuelEpargne()).isEqualByComparingTo("0.05");
assertThat(p.getTauxDividendePartsAnnuel()).isEqualByComparingTo("0.07");
assertThat(p.getPeriodiciteCalcul()).isEqualTo("TRIMESTRIEL");
assertThat(p.getSeuilMinEpargneInterets()).isEqualByComparingTo("500");
assertThat(p.getProchaineCalculInterets()).isEqualTo(prochaine);
assertThat(p.getDernierCalculInterets()).isEqualTo(dernier);
assertThat(p.getDernierNbComptesTraites()).isEqualTo(42);
}
// ─── builder ──────────────────────────────────────────────────────────────
@Test
@DisplayName("builder : valeurs par défaut (@Builder.Default)")
void builder_defaults() {
ParametresFinanciersMutuelle p = ParametresFinanciersMutuelle.builder().build();
assertThat(p.getValeurNominaleParDefaut()).isEqualByComparingTo("5000");
assertThat(p.getTauxInteretAnnuelEpargne()).isEqualByComparingTo("0.03");
assertThat(p.getTauxDividendePartsAnnuel()).isEqualByComparingTo("0.05");
assertThat(p.getPeriodiciteCalcul()).isEqualTo("MENSUEL");
assertThat(p.getSeuilMinEpargneInterets()).isEqualByComparingTo("0");
assertThat(p.getDernierNbComptesTraites()).isEqualTo(0);
}
@Test
@DisplayName("builder : valeurs personnalisées")
void builder_customValues() {
Organisation org = new Organisation();
org.setId(UUID.randomUUID());
LocalDate prochaine = LocalDate.of(2026, 10, 1);
LocalDate dernier = LocalDate.of(2026, 7, 1);
ParametresFinanciersMutuelle p = ParametresFinanciersMutuelle.builder()
.organisation(org)
.valeurNominaleParDefaut(new BigDecimal("2500"))
.tauxInteretAnnuelEpargne(new BigDecimal("0.04"))
.tauxDividendePartsAnnuel(new BigDecimal("0.06"))
.periodiciteCalcul("ANNUEL")
.seuilMinEpargneInterets(new BigDecimal("1000"))
.prochaineCalculInterets(prochaine)
.dernierCalculInterets(dernier)
.dernierNbComptesTraites(15)
.build();
assertThat(p.getOrganisation()).isSameAs(org);
assertThat(p.getValeurNominaleParDefaut()).isEqualByComparingTo("2500");
assertThat(p.getTauxInteretAnnuelEpargne()).isEqualByComparingTo("0.04");
assertThat(p.getTauxDividendePartsAnnuel()).isEqualByComparingTo("0.06");
assertThat(p.getPeriodiciteCalcul()).isEqualTo("ANNUEL");
assertThat(p.getSeuilMinEpargneInterets()).isEqualByComparingTo("1000");
assertThat(p.getProchaineCalculInterets()).isEqualTo(prochaine);
assertThat(p.getDernierCalculInterets()).isEqualTo(dernier);
assertThat(p.getDernierNbComptesTraites()).isEqualTo(15);
}
// ─── AllArgsConstructor ───────────────────────────────────────────────────
@Test
@DisplayName("AllArgsConstructor : instanciation complète")
void allArgsConstructor() {
Organisation org = new Organisation();
BigDecimal valeur = new BigDecimal("5000");
BigDecimal tauxEpargne = new BigDecimal("0.03");
BigDecimal tauxDivide = new BigDecimal("0.05");
String periodicite = "MENSUEL";
BigDecimal seuil = BigDecimal.ZERO;
LocalDate prochaine = LocalDate.of(2026, 6, 1);
LocalDate dernier = LocalDate.of(2026, 3, 1);
int nbComptes = 10;
ParametresFinanciersMutuelle p = new ParametresFinanciersMutuelle(
org, valeur, tauxEpargne, tauxDivide, periodicite, seuil,
prochaine, dernier, nbComptes);
assertThat(p.getOrganisation()).isSameAs(org);
assertThat(p.getValeurNominaleParDefaut()).isEqualByComparingTo("5000");
assertThat(p.getTauxInteretAnnuelEpargne()).isEqualByComparingTo("0.03");
assertThat(p.getTauxDividendePartsAnnuel()).isEqualByComparingTo("0.05");
assertThat(p.getPeriodiciteCalcul()).isEqualTo("MENSUEL");
assertThat(p.getSeuilMinEpargneInterets()).isEqualByComparingTo("0");
assertThat(p.getProchaineCalculInterets()).isEqualTo(prochaine);
assertThat(p.getDernierCalculInterets()).isEqualTo(dernier);
assertThat(p.getDernierNbComptesTraites()).isEqualTo(10);
}
// ─── equals / hashCode / toString ─────────────────────────────────────────
@Test
@DisplayName("equals : deux instances avec même id sont égales")
void equals_sameId() {
UUID id = UUID.randomUUID();
ParametresFinanciersMutuelle a = ParametresFinanciersMutuelle.builder()
.valeurNominaleParDefaut(new BigDecimal("5000"))
.tauxInteretAnnuelEpargne(new BigDecimal("0.03"))
.tauxDividendePartsAnnuel(new BigDecimal("0.05"))
.periodiciteCalcul("MENSUEL")
.seuilMinEpargneInterets(BigDecimal.ZERO)
.dernierNbComptesTraites(0)
.build();
a.setId(id);
ParametresFinanciersMutuelle b = ParametresFinanciersMutuelle.builder()
.valeurNominaleParDefaut(new BigDecimal("5000"))
.tauxInteretAnnuelEpargne(new BigDecimal("0.03"))
.tauxDividendePartsAnnuel(new BigDecimal("0.05"))
.periodiciteCalcul("MENSUEL")
.seuilMinEpargneInterets(BigDecimal.ZERO)
.dernierNbComptesTraites(0)
.build();
b.setId(id);
assertThat(a).isEqualTo(b);
assertThat(a.hashCode()).isEqualTo(b.hashCode());
}
@Test
@DisplayName("equals : deux instances avec id différents ne sont pas égales")
void equals_differentId() {
ParametresFinanciersMutuelle a = ParametresFinanciersMutuelle.builder().build();
a.setId(UUID.randomUUID());
ParametresFinanciersMutuelle b = ParametresFinanciersMutuelle.builder().build();
b.setId(UUID.randomUUID());
assertThat(a).isNotEqualTo(b);
}
@Test
@DisplayName("toString : non null et non vide")
void toString_notNull() {
ParametresFinanciersMutuelle p = ParametresFinanciersMutuelle.builder().build();
assertThat(p.toString()).isNotNull().isNotEmpty();
}
// ─── héritage BaseEntity ───────────────────────────────────────────────────
@Test
@DisplayName("héritage BaseEntity : setId / getId fonctionnels")
void baseEntity_idFieldsWork() {
ParametresFinanciersMutuelle p = new ParametresFinanciersMutuelle();
UUID id = UUID.randomUUID();
p.setId(id);
p.setActif(true);
p.setCreePar("admin@test.com");
p.setModifiePar("admin2@test.com");
assertThat(p.getId()).isEqualTo(id);
assertThat(p.getActif()).isTrue();
assertThat(p.getCreePar()).isEqualTo("admin@test.com");
assertThat(p.getModifiePar()).isEqualTo("admin2@test.com");
}
@Test
@DisplayName("marquerCommeModifie : met à jour modifiePar et dateModification")
void marquerCommeModifie_updatesFields() {
ParametresFinanciersMutuelle p = new ParametresFinanciersMutuelle();
p.marquerCommeModifie("gestionnaire@test.com");
assertThat(p.getModifiePar()).isEqualTo("gestionnaire@test.com");
assertThat(p.getDateModification()).isNotNull();
}
}

View File

@@ -0,0 +1,230 @@
package dev.lions.unionflow.server.entity.mutuelle.parts;
import dev.lions.unionflow.server.api.enums.mutuelle.parts.StatutComptePartsSociales;
import dev.lions.unionflow.server.entity.Membre;
import dev.lions.unionflow.server.entity.Organisation;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("ComptePartsSociales — entité")
class ComptePartsSocialesTest {
// ─── constructeur no-arg ──────────────────────────────────────────────────
@Test
@DisplayName("constructeur no-arg : instance créée, champs null sans @Builder.Default")
void noArgConstructor_instanceCreated() {
ComptePartsSociales c = new ComptePartsSociales();
assertThat(c).isNotNull();
assertThat(c.getMembre()).isNull();
assertThat(c.getOrganisation()).isNull();
assertThat(c.getNumeroCompte()).isNull();
}
// ─── setters / getters ────────────────────────────────────────────────────
@Test
@DisplayName("setters et getters — tous les champs")
void settersGetters_allFields() {
ComptePartsSociales c = new ComptePartsSociales();
Membre membre = new Membre();
membre.setId(UUID.randomUUID());
Organisation org = new Organisation();
org.setId(UUID.randomUUID());
LocalDate ouverture = LocalDate.of(2025, 1, 15);
LocalDate derniereOp = LocalDate.of(2026, 4, 1);
c.setMembre(membre);
c.setOrganisation(org);
c.setNumeroCompte("CPS-2025-001");
c.setNombreParts(10);
c.setValeurNominale(new BigDecimal("5000"));
c.setMontantTotal(new BigDecimal("50000"));
c.setTotalDividendesRecus(new BigDecimal("2500"));
c.setStatut(StatutComptePartsSociales.ACTIF);
c.setDateOuverture(ouverture);
c.setDateDerniereOperation(derniereOp);
c.setNotes("Note de test");
assertThat(c.getMembre()).isSameAs(membre);
assertThat(c.getOrganisation()).isSameAs(org);
assertThat(c.getNumeroCompte()).isEqualTo("CPS-2025-001");
assertThat(c.getNombreParts()).isEqualTo(10);
assertThat(c.getValeurNominale()).isEqualByComparingTo("5000");
assertThat(c.getMontantTotal()).isEqualByComparingTo("50000");
assertThat(c.getTotalDividendesRecus()).isEqualByComparingTo("2500");
assertThat(c.getStatut()).isEqualTo(StatutComptePartsSociales.ACTIF);
assertThat(c.getDateOuverture()).isEqualTo(ouverture);
assertThat(c.getDateDerniereOperation()).isEqualTo(derniereOp);
assertThat(c.getNotes()).isEqualTo("Note de test");
}
// ─── builder ──────────────────────────────────────────────────────────────
@Test
@DisplayName("builder : valeurs par défaut (@Builder.Default)")
void builder_defaults() {
ComptePartsSociales c = ComptePartsSociales.builder()
.numeroCompte("CPS-TEST-001")
.valeurNominale(new BigDecimal("5000"))
.build();
assertThat(c.getNombreParts()).isEqualTo(0);
assertThat(c.getMontantTotal()).isEqualByComparingTo("0");
assertThat(c.getTotalDividendesRecus()).isEqualByComparingTo("0");
assertThat(c.getStatut()).isEqualTo(StatutComptePartsSociales.ACTIF);
assertThat(c.getDateOuverture()).isEqualTo(LocalDate.now());
}
@Test
@DisplayName("builder : valeurs personnalisées")
void builder_customValues() {
Membre membre = new Membre();
Organisation org = new Organisation();
LocalDate ouverture = LocalDate.of(2024, 6, 1);
ComptePartsSociales c = ComptePartsSociales.builder()
.membre(membre)
.organisation(org)
.numeroCompte("CPS-2024-042")
.nombreParts(25)
.valeurNominale(new BigDecimal("5000"))
.montantTotal(new BigDecimal("125000"))
.totalDividendesRecus(new BigDecimal("6250"))
.statut(StatutComptePartsSociales.SUSPENDU)
.dateOuverture(ouverture)
.notes("Compte suspendu pour régularisation")
.build();
assertThat(c.getMembre()).isSameAs(membre);
assertThat(c.getOrganisation()).isSameAs(org);
assertThat(c.getNumeroCompte()).isEqualTo("CPS-2024-042");
assertThat(c.getNombreParts()).isEqualTo(25);
assertThat(c.getValeurNominale()).isEqualByComparingTo("5000");
assertThat(c.getMontantTotal()).isEqualByComparingTo("125000");
assertThat(c.getTotalDividendesRecus()).isEqualByComparingTo("6250");
assertThat(c.getStatut()).isEqualTo(StatutComptePartsSociales.SUSPENDU);
assertThat(c.getDateOuverture()).isEqualTo(ouverture);
assertThat(c.getNotes()).isEqualTo("Compte suspendu pour régularisation");
}
// ─── StatutComptePartsSociales enum ──────────────────────────────────────
@Test
@DisplayName("StatutComptePartsSociales : toutes les valeurs accessibles")
void statutEnum_allValues() {
assertThat(StatutComptePartsSociales.values()).hasSize(3);
assertThat(StatutComptePartsSociales.ACTIF.getLibelle()).isEqualTo("Compte actif");
assertThat(StatutComptePartsSociales.SUSPENDU.getLibelle()).isEqualTo("Compte suspendu");
assertThat(StatutComptePartsSociales.CLOS.getLibelle()).contains("Compte cl");
}
@Test
@DisplayName("StatutComptePartsSociales : valueOf fonctionne")
void statutEnum_valueOf() {
assertThat(StatutComptePartsSociales.valueOf("ACTIF")).isEqualTo(StatutComptePartsSociales.ACTIF);
assertThat(StatutComptePartsSociales.valueOf("CLOS")).isEqualTo(StatutComptePartsSociales.CLOS);
}
// ─── AllArgsConstructor ───────────────────────────────────────────────────
@Test
@DisplayName("AllArgsConstructor : instanciation complète")
void allArgsConstructor() {
Membre membre = new Membre();
Organisation org = new Organisation();
String numero = "CPS-ALL-001";
Integer nombreParts = 5;
BigDecimal valeurNominale = new BigDecimal("5000");
BigDecimal montantTotal = new BigDecimal("25000");
BigDecimal totalDividendes = new BigDecimal("1250");
StatutComptePartsSociales statut = StatutComptePartsSociales.ACTIF;
LocalDate dateOuverture = LocalDate.of(2025, 3, 1);
LocalDate dateDerniereOperation = LocalDate.of(2026, 4, 1);
String notes = "Test all args";
ComptePartsSociales c = new ComptePartsSociales(
membre, org, numero, nombreParts, valeurNominale,
montantTotal, totalDividendes, statut,
dateOuverture, dateDerniereOperation, notes);
assertThat(c.getMembre()).isSameAs(membre);
assertThat(c.getOrganisation()).isSameAs(org);
assertThat(c.getNumeroCompte()).isEqualTo("CPS-ALL-001");
assertThat(c.getNombreParts()).isEqualTo(5);
assertThat(c.getStatut()).isEqualTo(StatutComptePartsSociales.ACTIF);
}
// ─── equals / hashCode / toString ─────────────────────────────────────────
@Test
@DisplayName("equals : même id → égaux")
void equals_sameId() {
UUID id = UUID.randomUUID();
ComptePartsSociales a = ComptePartsSociales.builder()
.numeroCompte("CPS-A")
.valeurNominale(new BigDecimal("5000"))
.build();
a.setId(id);
ComptePartsSociales b = ComptePartsSociales.builder()
.numeroCompte("CPS-A")
.valeurNominale(new BigDecimal("5000"))
.build();
b.setId(id);
assertThat(a).isEqualTo(b);
assertThat(a.hashCode()).isEqualTo(b.hashCode());
}
@Test
@DisplayName("equals : id différents → non égaux")
void equals_differentId() {
ComptePartsSociales a = ComptePartsSociales.builder()
.numeroCompte("CPS-A")
.valeurNominale(new BigDecimal("5000"))
.build();
a.setId(UUID.randomUUID());
ComptePartsSociales b = ComptePartsSociales.builder()
.numeroCompte("CPS-A")
.valeurNominale(new BigDecimal("5000"))
.build();
b.setId(UUID.randomUUID());
assertThat(a).isNotEqualTo(b);
}
@Test
@DisplayName("toString : non null, non vide")
void toString_notNull() {
ComptePartsSociales c = ComptePartsSociales.builder()
.numeroCompte("CPS-STR")
.valeurNominale(new BigDecimal("5000"))
.build();
assertThat(c.toString()).isNotNull().isNotEmpty();
}
// ─── BaseEntity ───────────────────────────────────────────────────────────
@Test
@DisplayName("BaseEntity : id, actif, audit fields accessibles")
void baseEntity_fields() {
ComptePartsSociales c = new ComptePartsSociales();
UUID id = UUID.randomUUID();
c.setId(id);
c.setActif(false);
c.setCreePar("createur@test.com");
assertThat(c.getId()).isEqualTo(id);
assertThat(c.getActif()).isFalse();
assertThat(c.getCreePar()).isEqualTo("createur@test.com");
}
}

View File

@@ -0,0 +1,257 @@
package dev.lions.unionflow.server.entity.mutuelle.parts;
import dev.lions.unionflow.server.api.enums.mutuelle.parts.TypeTransactionPartsSociales;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("TransactionPartsSociales — entité")
class TransactionPartsSocialesTest {
// ─── constructeur no-arg ──────────────────────────────────────────────────
@Test
@DisplayName("constructeur no-arg : instance créée")
void noArgConstructor_instanceCreated() {
TransactionPartsSociales t = new TransactionPartsSociales();
assertThat(t).isNotNull();
assertThat(t.getCompte()).isNull();
assertThat(t.getTypeTransaction()).isNull();
assertThat(t.getNombreParts()).isNull();
assertThat(t.getMontant()).isNull();
}
// ─── setters / getters ────────────────────────────────────────────────────
@Test
@DisplayName("setters et getters — tous les champs")
void settersGetters_allFields() {
TransactionPartsSociales t = new TransactionPartsSociales();
ComptePartsSociales compte = ComptePartsSociales.builder()
.numeroCompte("CPS-001")
.valeurNominale(new BigDecimal("5000"))
.build();
compte.setId(UUID.randomUUID());
LocalDateTime dateTransaction = LocalDateTime.of(2026, 4, 20, 10, 30);
t.setCompte(compte);
t.setTypeTransaction(TypeTransactionPartsSociales.SOUSCRIPTION);
t.setNombreParts(5);
t.setMontant(new BigDecimal("25000"));
t.setSoldePartsAvant(10);
t.setSoldePartsApres(15);
t.setMotif("Souscription initiale");
t.setReferenceExterne("REF-EXT-001");
t.setDateTransaction(dateTransaction);
assertThat(t.getCompte()).isSameAs(compte);
assertThat(t.getTypeTransaction()).isEqualTo(TypeTransactionPartsSociales.SOUSCRIPTION);
assertThat(t.getNombreParts()).isEqualTo(5);
assertThat(t.getMontant()).isEqualByComparingTo("25000");
assertThat(t.getSoldePartsAvant()).isEqualTo(10);
assertThat(t.getSoldePartsApres()).isEqualTo(15);
assertThat(t.getMotif()).isEqualTo("Souscription initiale");
assertThat(t.getReferenceExterne()).isEqualTo("REF-EXT-001");
assertThat(t.getDateTransaction()).isEqualTo(dateTransaction);
}
// ─── builder ──────────────────────────────────────────────────────────────
@Test
@DisplayName("builder : valeurs par défaut (@Builder.Default)")
void builder_defaults() {
TransactionPartsSociales t = TransactionPartsSociales.builder()
.typeTransaction(TypeTransactionPartsSociales.SOUSCRIPTION)
.nombreParts(1)
.montant(new BigDecimal("5000"))
.build();
assertThat(t.getSoldePartsAvant()).isEqualTo(0);
assertThat(t.getSoldePartsApres()).isEqualTo(0);
assertThat(t.getDateTransaction()).isNotNull();
// dateTransaction initialized to LocalDateTime.now() by @Builder.Default
assertThat(t.getDateTransaction()).isBeforeOrEqualTo(LocalDateTime.now());
}
@Test
@DisplayName("builder : valeurs personnalisées")
void builder_customValues() {
ComptePartsSociales compte = ComptePartsSociales.builder()
.numeroCompte("CPS-002")
.valeurNominale(new BigDecimal("5000"))
.build();
LocalDateTime now = LocalDateTime.now();
TransactionPartsSociales t = TransactionPartsSociales.builder()
.compte(compte)
.typeTransaction(TypeTransactionPartsSociales.CESSION_PARTIELLE)
.nombreParts(3)
.montant(new BigDecimal("15000"))
.soldePartsAvant(10)
.soldePartsApres(7)
.motif("Cession à titre onéreux")
.referenceExterne("REF-CESS-001")
.dateTransaction(now)
.build();
assertThat(t.getCompte()).isSameAs(compte);
assertThat(t.getTypeTransaction()).isEqualTo(TypeTransactionPartsSociales.CESSION_PARTIELLE);
assertThat(t.getNombreParts()).isEqualTo(3);
assertThat(t.getMontant()).isEqualByComparingTo("15000");
assertThat(t.getSoldePartsAvant()).isEqualTo(10);
assertThat(t.getSoldePartsApres()).isEqualTo(7);
assertThat(t.getMotif()).isEqualTo("Cession à titre onéreux");
assertThat(t.getReferenceExterne()).isEqualTo("REF-CESS-001");
assertThat(t.getDateTransaction()).isEqualTo(now);
}
// ─── AllArgsConstructor ───────────────────────────────────────────────────
@Test
@DisplayName("AllArgsConstructor : instanciation complète")
void allArgsConstructor() {
ComptePartsSociales compte = ComptePartsSociales.builder()
.numeroCompte("CPS-ALL")
.valeurNominale(new BigDecimal("5000"))
.build();
TypeTransactionPartsSociales type = TypeTransactionPartsSociales.RACHAT_TOTAL;
Integer nombreParts = 20;
BigDecimal montant = new BigDecimal("100000");
Integer soldeAvant = 20;
Integer soldeApres = 0;
String motif = "Rachat complet";
String refExterne = "REF-RACHAT-001";
LocalDateTime dateTransaction = LocalDateTime.of(2026, 4, 20, 12, 0);
TransactionPartsSociales t = new TransactionPartsSociales(
compte, type, nombreParts, montant,
soldeAvant, soldeApres, motif, refExterne, dateTransaction);
assertThat(t.getCompte()).isSameAs(compte);
assertThat(t.getTypeTransaction()).isEqualTo(TypeTransactionPartsSociales.RACHAT_TOTAL);
assertThat(t.getNombreParts()).isEqualTo(20);
assertThat(t.getMontant()).isEqualByComparingTo("100000");
assertThat(t.getSoldePartsAvant()).isEqualTo(20);
assertThat(t.getSoldePartsApres()).isEqualTo(0);
assertThat(t.getMotif()).isEqualTo("Rachat complet");
assertThat(t.getReferenceExterne()).isEqualTo("REF-RACHAT-001");
assertThat(t.getDateTransaction()).isEqualTo(dateTransaction);
}
// ─── TypeTransactionPartsSociales enum ───────────────────────────────────
@Test
@DisplayName("TypeTransactionPartsSociales : toutes les valeurs accessibles avec libellé")
void typeTransactionEnum_allValues() {
assertThat(TypeTransactionPartsSociales.values()).hasSize(6);
assertThat(TypeTransactionPartsSociales.SOUSCRIPTION.getLibelle())
.isEqualTo("Souscription de parts sociales");
assertThat(TypeTransactionPartsSociales.SOUSCRIPTION_IMPORT.getLibelle())
.contains("Import");
assertThat(TypeTransactionPartsSociales.CESSION_PARTIELLE.getLibelle())
.contains("Cession");
assertThat(TypeTransactionPartsSociales.RACHAT_TOTAL.getLibelle())
.contains("Rachat");
assertThat(TypeTransactionPartsSociales.PAIEMENT_DIVIDENDE.getLibelle())
.contains("dividende");
assertThat(TypeTransactionPartsSociales.CORRECTION.getLibelle())
.contains("Correction");
}
@Test
@DisplayName("TypeTransactionPartsSociales : valueOf fonctionne")
void typeTransactionEnum_valueOf() {
assertThat(TypeTransactionPartsSociales.valueOf("SOUSCRIPTION"))
.isEqualTo(TypeTransactionPartsSociales.SOUSCRIPTION);
assertThat(TypeTransactionPartsSociales.valueOf("PAIEMENT_DIVIDENDE"))
.isEqualTo(TypeTransactionPartsSociales.PAIEMENT_DIVIDENDE);
}
// ─── equals / hashCode / toString ─────────────────────────────────────────
@Test
@DisplayName("equals : même id → égaux")
void equals_sameId() {
UUID id = UUID.randomUUID();
TransactionPartsSociales a = TransactionPartsSociales.builder()
.typeTransaction(TypeTransactionPartsSociales.SOUSCRIPTION)
.nombreParts(1)
.montant(new BigDecimal("5000"))
.build();
a.setId(id);
TransactionPartsSociales b = TransactionPartsSociales.builder()
.typeTransaction(TypeTransactionPartsSociales.SOUSCRIPTION)
.nombreParts(1)
.montant(new BigDecimal("5000"))
.build();
b.setId(id);
assertThat(a).isEqualTo(b);
assertThat(a.hashCode()).isEqualTo(b.hashCode());
}
@Test
@DisplayName("equals : id différents → non égaux")
void equals_differentId() {
TransactionPartsSociales a = TransactionPartsSociales.builder()
.typeTransaction(TypeTransactionPartsSociales.SOUSCRIPTION)
.nombreParts(1)
.montant(new BigDecimal("5000"))
.build();
a.setId(UUID.randomUUID());
TransactionPartsSociales b = TransactionPartsSociales.builder()
.typeTransaction(TypeTransactionPartsSociales.SOUSCRIPTION)
.nombreParts(1)
.montant(new BigDecimal("5000"))
.build();
b.setId(UUID.randomUUID());
assertThat(a).isNotEqualTo(b);
}
@Test
@DisplayName("toString : non null et non vide")
void toString_notNull() {
TransactionPartsSociales t = TransactionPartsSociales.builder()
.typeTransaction(TypeTransactionPartsSociales.CORRECTION)
.nombreParts(1)
.montant(BigDecimal.ZERO)
.build();
assertThat(t.toString()).isNotNull().isNotEmpty();
}
// ─── BaseEntity ───────────────────────────────────────────────────────────
@Test
@DisplayName("BaseEntity : id, actif, audit fields accessibles")
void baseEntity_fields() {
TransactionPartsSociales t = new TransactionPartsSociales();
UUID id = UUID.randomUUID();
t.setId(id);
t.setActif(true);
t.setVersion(3L);
assertThat(t.getId()).isEqualTo(id);
assertThat(t.getActif()).isTrue();
assertThat(t.getVersion()).isEqualTo(3L);
}
@Test
@DisplayName("marquerCommeModifie : met à jour modifiePar et dateModification")
void marquerCommeModifie_updatesFields() {
TransactionPartsSociales t = new TransactionPartsSociales();
t.marquerCommeModifie("comptable@test.com");
assertThat(t.getModifiePar()).isEqualTo("comptable@test.com");
assertThat(t.getDateModification()).isNotNull();
}
}

View File

@@ -0,0 +1,64 @@
package dev.lions.unionflow.server.integration;
import io.quarkus.test.junit.QuarkusTestProfile;
import java.util.HashMap;
import java.util.Map;
/**
* Profil test d'intégration.
*
* <p>Si Docker est disponible (DOCKER_HOST ou npipe accessible), active DevServices PostgreSQL
* via Testcontainers. Sinon, utilise le PostgreSQL local configuré dans application.properties
* (localhost:5432/unionflow) — aucun Docker requis pour le développement local.
*
* <p>Usage : annoter la classe de test avec :
* <pre>
* {@literal @}QuarkusTest
* {@literal @}TestProfile(IntegrationTestProfile.class)
* class MonIntegrationTest { ... }
* </pre>
*/
public class IntegrationTestProfile implements QuarkusTestProfile {
private static final boolean DOCKER_AVAILABLE = isDockerAvailable();
@Override
public String getConfigProfile() {
return "integration-test";
}
@Override
public Map<String, String> getConfigOverrides() {
Map<String, String> config = new HashMap<>();
if (DOCKER_AVAILABLE) {
config.put("quarkus.devservices.enabled", "true");
config.put("quarkus.datasource.devservices.reuse", "true");
config.put("quarkus.datasource.devservices.image-name", "postgres:17-alpine");
} else {
// Sans Docker : utiliser le PostgreSQL local (dev env)
config.put("quarkus.devservices.enabled", "false");
}
config.put("quarkus.mailer.mock", "true");
return config;
}
private static boolean isDockerAvailable() {
// Opt-in explicite via variable d'environnement (CI ou dev avec Docker actif)
String flag = System.getenv("USE_DOCKER_TESTS");
if ("true".equalsIgnoreCase(flag)) {
return true;
}
// Vérification réelle : docker info doit répondre sans erreur
try {
ProcessBuilder pb = new ProcessBuilder("docker", "info", "--format", "{{.ServerVersion}}");
pb.redirectErrorStream(true);
Process process = pb.start();
// Vider stdout pour éviter un blocage sur le buffer
process.getInputStream().transferTo(java.io.OutputStream.nullOutputStream());
int exitCode = process.waitFor();
return exitCode == 0;
} catch (Exception e) {
return false;
}
}
}

View File

@@ -0,0 +1,121 @@
package dev.lions.unionflow.server.integration;
import dev.lions.unionflow.server.entity.*;
import dev.lions.unionflow.server.repository.*;
import io.quarkus.test.junit.QuarkusTest;
import io.quarkus.test.junit.TestProfile;
import jakarta.inject.Inject;
import jakarta.persistence.EntityManager;
import jakarta.transaction.Transactional;
import org.junit.jupiter.api.*;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Tests d'isolation cross-tenant via PostgreSQL RLS (Row-Level Security).
*
* <p>Vérifie qu'un membre d'une organisation ne peut pas accéder aux données d'une autre
* organisation même avec un accès direct au repository (contournement intentionnel pour test).
*
* <p>NOTE : ces tests utilisent PostgreSQL réel (DevServices).
* Ils ne passent PAS avec H2 (RLS non supporté par H2).
* Lancer avec : {@code mvn test -Dquarkus.test.profile=integration-test}
*/
@QuarkusTest
@TestProfile(IntegrationTestProfile.class)
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class RlsCrossTenantIsolationTest {
@Inject
OrganisationRepository organisationRepository;
@Inject
CotisationRepository cotisationRepository;
@Inject
MembreRepository membreRepository;
@Inject
EntityManager em;
private static UUID orgAId;
private static UUID orgBId;
@BeforeEach
@Transactional
void setup() {
// Créer deux organisations de test
Organisation orgA = new Organisation();
orgA.setNom("Org Test A RLS");
orgA.setActif(true);
organisationRepository.persist(orgA);
orgAId = orgA.getId();
Organisation orgB = new Organisation();
orgB.setNom("Org Test B RLS");
orgB.setActif(true);
organisationRepository.persist(orgB);
orgBId = orgB.getId();
// Créer un membre pour orgA
Membre membreA = new Membre();
membreA.setEmail("membre-a@test.rls");
membreA.setPrenom("Membre");
membreA.setNom("A");
membreA.setActif(true);
membreRepository.persist(membreA);
// Créer une cotisation pour orgA
Cotisation cotisationA = new Cotisation();
cotisationA.setOrganisation(orgA);
cotisationA.setMembre(membreA);
cotisationA.setMontantDu(BigDecimal.valueOf(5000));
cotisationA.setMontantPaye(BigDecimal.ZERO);
cotisationA.setStatut("EN_ATTENTE");
cotisationA.setDateEcheance(LocalDate.now().plusDays(30));
cotisationA.setPeriode("2026/04");
cotisationRepository.persist(cotisationA);
}
@Test
@Order(1)
@Transactional
void sansSuperAdmin_cotisationOrgA_visibleUniquementPourOrgA() {
// SET LOCAL en SQL direct pour simuler le comportement RLS du filtre JAX-RS
em.createNativeQuery("SET LOCAL app.current_org_id = '" + orgAId + "'").executeUpdate();
em.createNativeQuery("SET LOCAL app.is_super_admin = 'false'").executeUpdate();
List<Cotisation> cotisationsVues = cotisationRepository.find("organisation.id", orgAId).list();
assertThat(cotisationsVues).isNotEmpty();
}
@Test
@Order(2)
@Transactional
void sansSuperAdmin_cotisationOrgA_invisibleDepuisOrgB() {
// Simuler le contexte de orgB — ne devrait pas voir les cotisations de orgA
em.createNativeQuery("SET LOCAL app.current_org_id = '" + orgBId + "'").executeUpdate();
em.createNativeQuery("SET LOCAL app.is_super_admin = 'false'").executeUpdate();
List<Cotisation> cotisationsVues = cotisationRepository.find("organisation.id", orgAId).list();
// Avec RLS actif : zéro résultat car orgB n'a pas accès aux données de orgA
assertThat(cotisationsVues).isEmpty();
}
@Test
@Order(3)
@Transactional
void avecSuperAdmin_cotisationOrgA_visibleDepuisOrgB() {
// SUPER_ADMIN contourne la politique RLS
em.createNativeQuery("SET LOCAL app.current_org_id = '" + orgBId + "'").executeUpdate();
em.createNativeQuery("SET LOCAL app.is_super_admin = 'true'").executeUpdate();
List<Cotisation> cotisationsVues = cotisationRepository.find("organisation.id", orgAId).list();
assertThat(cotisationsVues).isNotEmpty();
}
}

View File

@@ -0,0 +1,76 @@
package dev.lions.unionflow.server.payment;
import dev.lions.unionflow.server.api.payment.PaymentEvent;
import dev.lions.unionflow.server.api.payment.PaymentStatus;
import dev.lions.unionflow.server.payment.orchestration.PaymentOrchestrator;
import dev.lions.unionflow.server.payment.orchestration.PaymentProviderRegistry;
import dev.lions.unionflow.server.service.PaiementService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.math.BigDecimal;
import java.time.Instant;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class PaymentOrchestratorHandleEventTest {
@Mock
PaymentProviderRegistry registry;
@Mock
PaiementService paiementService;
@InjectMocks
PaymentOrchestrator orchestrator;
@BeforeEach
void setup() throws Exception {
// Inject default config values via reflection
var defaultProviderField = PaymentOrchestrator.class.getDeclaredField("defaultProvider");
defaultProviderField.setAccessible(true);
defaultProviderField.set(orchestrator, "WAVE");
var pispiField = PaymentOrchestrator.class.getDeclaredField("pispiPriority");
pispiField.setAccessible(true);
pispiField.set(orchestrator, false);
}
@Test
void handleEvent_delegatesToPaiementService() {
PaymentEvent event = new PaymentEvent(
"ext-123", "PAY-REF-001", PaymentStatus.SUCCESS,
BigDecimal.valueOf(5000), "TXN-ABC", Instant.now());
orchestrator.handleEvent(event);
verify(paiementService).mettreAJourStatutDepuisWebhook(event);
}
@Test
void handleEvent_withFailedStatus_delegatesToPaiementService() {
PaymentEvent event = new PaymentEvent(
"ext-456", "PAY-REF-002", PaymentStatus.FAILED,
BigDecimal.ZERO, null, Instant.now());
orchestrator.handleEvent(event);
verify(paiementService).mettreAJourStatutDepuisWebhook(event);
}
@Test
void handleEvent_withCancelledStatus_delegatesToPaiementService() {
PaymentEvent event = new PaymentEvent(
"ext-789", "PAY-REF-003", PaymentStatus.CANCELLED,
BigDecimal.ZERO, null, Instant.now());
orchestrator.handleEvent(event);
verify(paiementService).mettreAJourStatutDepuisWebhook(event);
}
}

View File

@@ -0,0 +1,79 @@
package dev.lions.unionflow.server.payment;
import dev.lions.unionflow.server.api.payment.CheckoutRequest;
import dev.lions.unionflow.server.api.payment.PaymentException;
import dev.lions.unionflow.server.api.payment.PaymentStatus;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.util.Map;
import static org.assertj.core.api.Assertions.*;
class PaymentProviderTest {
@Test
@DisplayName("CheckoutRequest — montant nul lève IllegalArgumentException")
void checkoutRequest_montantNull_throws() {
assertThatIllegalArgumentException().isThrownBy(() ->
new CheckoutRequest(null, "XOF", "+2250700000001",
"user@example.com", "REF-001", "https://ok", "https://cancel", Map.of())
).withMessageContaining("amount");
}
@Test
@DisplayName("CheckoutRequest — montant négatif lève IllegalArgumentException")
void checkoutRequest_montantNegatif_throws() {
assertThatIllegalArgumentException().isThrownBy(() ->
new CheckoutRequest(BigDecimal.valueOf(-100), "XOF", null,
null, "REF-002", "https://ok", "https://cancel", Map.of())
).withMessageContaining("amount");
}
@Test
@DisplayName("CheckoutRequest — devise vide lève IllegalArgumentException")
void checkoutRequest_deviseVide_throws() {
assertThatIllegalArgumentException().isThrownBy(() ->
new CheckoutRequest(BigDecimal.valueOf(5000), "", null,
null, "REF-003", "https://ok", "https://cancel", Map.of())
).withMessageContaining("currency");
}
@Test
@DisplayName("CheckoutRequest — référence vide lève IllegalArgumentException")
void checkoutRequest_referenceVide_throws() {
assertThatIllegalArgumentException().isThrownBy(() ->
new CheckoutRequest(BigDecimal.valueOf(5000), "XOF", null,
null, "", "https://ok", "https://cancel", Map.of())
).withMessageContaining("reference");
}
@Test
@DisplayName("CheckoutRequest — valide sans erreur")
void checkoutRequest_valide_ok() {
assertThatNoException().isThrownBy(() ->
new CheckoutRequest(BigDecimal.valueOf(10000), "XOF",
"+2250700000001", "user@test.ci",
"SOUSCRIPTION-UUID-123", "https://ok.ci", "https://cancel.ci",
Map.of("org", "mutuelle-1"))
);
}
@Test
@DisplayName("PaymentException — getHttpStatus et getProviderCode corrects")
void paymentException_fields() {
PaymentException ex = new PaymentException("WAVE", "Test error", 400);
assertThat(ex.getHttpStatus()).isEqualTo(400);
assertThat(ex.getProviderCode()).isEqualTo("WAVE");
assertThat(ex.getMessage()).contains("WAVE").contains("Test error");
}
@Test
@DisplayName("PaymentStatus — tous les statuts attendus présents")
void paymentStatus_allValues() {
var statuses = java.util.Arrays.stream(PaymentStatus.values())
.map(Enum::name).toList();
assertThat(statuses).contains("INITIATED", "PROCESSING", "SUCCESS", "FAILED", "CANCELLED", "EXPIRED");
}
}

View File

@@ -0,0 +1,125 @@
package dev.lions.unionflow.server.payment.mtnmomo;
import dev.lions.unionflow.server.api.payment.CheckoutRequest;
import dev.lions.unionflow.server.api.payment.CheckoutSession;
import dev.lions.unionflow.server.api.payment.PaymentException;
import dev.lions.unionflow.server.api.payment.PaymentStatus;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Field;
import java.math.BigDecimal;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class MtnMomoPaymentProviderTest {
private MtnMomoPaymentProvider provider;
@BeforeEach
void setUp() {
provider = new MtnMomoPaymentProvider();
// subscriptionKey defaults to "" — mock mode active
}
@Test
void getProviderCode_returns_MTN_MOMO() {
assertThat(provider.getProviderCode()).isEqualTo("MTN_MOMO");
}
@Test
void code_constant_is_MTN_MOMO() {
assertThat(MtnMomoPaymentProvider.CODE).isEqualTo("MTN_MOMO");
}
@Test
void initiateCheckout_whenNotConfigured_returnsMockSession() throws Exception {
CheckoutRequest req = new CheckoutRequest(
BigDecimal.valueOf(5000), "XOF",
"+2250100000000", "test@test.com",
"REF-001", "http://success", "http://cancel", Map.of());
CheckoutSession session = provider.initiateCheckout(req);
assertThat(session.externalId()).startsWith("MTN-MOCK-");
assertThat(session.checkoutUrl()).contains("mock.mtn.ci");
assertThat(session.expiresAt()).isNotNull();
assertThat(session.providerMetadata()).containsEntry("mock", "true");
assertThat(session.providerMetadata()).containsEntry("provider", "MTN_MOMO");
}
@Test
void initiateCheckout_whenConfigured_throwsNotImplemented() throws Exception {
Field f = MtnMomoPaymentProvider.class.getDeclaredField("subscriptionKey");
f.setAccessible(true);
f.set(provider, "real-subscription-key");
CheckoutRequest req = new CheckoutRequest(
BigDecimal.valueOf(1000), "XOF",
"+2250100000001", "user@test.com",
"REF-002", "http://success", "http://cancel", Map.of());
assertThatThrownBy(() -> provider.initiateCheckout(req))
.isInstanceOf(PaymentException.class)
.hasMessageContaining("501");
}
@Test
void getStatus_returnsPROCESSING() throws Exception {
PaymentStatus status = provider.getStatus("MTN-EXT-123");
assertThat(status).isEqualTo(PaymentStatus.PROCESSING);
}
@Test
void processWebhook_throwsNotImplemented() {
assertThatThrownBy(() -> provider.processWebhook("{}", Map.of()))
.isInstanceOf(PaymentException.class)
.hasMessageContaining("501");
}
@Test
void isAvailable_whenSubscriptionKeyEmpty_returnsFalse() {
assertThat(provider.isAvailable()).isFalse();
}
@Test
void isAvailable_whenSubscriptionKeyBlank_returnsFalse() throws Exception {
Field f = MtnMomoPaymentProvider.class.getDeclaredField("subscriptionKey");
f.setAccessible(true);
f.set(provider, " ");
assertThat(provider.isAvailable()).isFalse();
}
@Test
void isAvailable_whenSubscriptionKeySet_returnsTrue() throws Exception {
Field f = MtnMomoPaymentProvider.class.getDeclaredField("subscriptionKey");
f.setAccessible(true);
f.set(provider, "real-key");
assertThat(provider.isAvailable()).isTrue();
}
@Test
void isAvailable_whenSubscriptionKeyNull_returnsFalse() throws Exception {
Field f = MtnMomoPaymentProvider.class.getDeclaredField("subscriptionKey");
f.setAccessible(true);
f.set(provider, null);
assertThat(provider.isAvailable()).isFalse();
}
@Test
void initiateCheckout_whenSubscriptionKeyNull_returnsMockSession() throws Exception {
Field f = MtnMomoPaymentProvider.class.getDeclaredField("subscriptionKey");
f.setAccessible(true);
f.set(provider, null);
CheckoutRequest req = new CheckoutRequest(
BigDecimal.valueOf(2000), "XOF",
"+2250100000002", "null@test.com",
"REF-NULL", "http://success", "http://cancel", Map.of());
CheckoutSession session = provider.initiateCheckout(req);
assertThat(session.externalId()).startsWith("MTN-MOCK-");
}
}

View File

@@ -0,0 +1,125 @@
package dev.lions.unionflow.server.payment.orangemoney;
import dev.lions.unionflow.server.api.payment.CheckoutRequest;
import dev.lions.unionflow.server.api.payment.CheckoutSession;
import dev.lions.unionflow.server.api.payment.PaymentException;
import dev.lions.unionflow.server.api.payment.PaymentStatus;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Field;
import java.math.BigDecimal;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class OrangeMoneyPaymentProviderTest {
private OrangeMoneyPaymentProvider provider;
@BeforeEach
void setUp() {
provider = new OrangeMoneyPaymentProvider();
// clientId defaults to "" — mock mode active
}
@Test
void getProviderCode_returns_ORANGE_MONEY() {
assertThat(provider.getProviderCode()).isEqualTo("ORANGE_MONEY");
}
@Test
void code_constant_is_ORANGE_MONEY() {
assertThat(OrangeMoneyPaymentProvider.CODE).isEqualTo("ORANGE_MONEY");
}
@Test
void initiateCheckout_whenNotConfigured_returnsMockSession() throws Exception {
CheckoutRequest req = new CheckoutRequest(
BigDecimal.valueOf(3000), "XOF",
"+2250700000000", "orange@test.com",
"OM-REF-001", "http://success", "http://cancel", Map.of());
CheckoutSession session = provider.initiateCheckout(req);
assertThat(session.externalId()).startsWith("OM-MOCK-");
assertThat(session.checkoutUrl()).contains("mock.orange.ci");
assertThat(session.expiresAt()).isNotNull();
assertThat(session.providerMetadata()).containsEntry("mock", "true");
assertThat(session.providerMetadata()).containsEntry("provider", "ORANGE_MONEY");
}
@Test
void initiateCheckout_whenConfigured_throwsNotImplemented() throws Exception {
Field f = OrangeMoneyPaymentProvider.class.getDeclaredField("clientId");
f.setAccessible(true);
f.set(provider, "real-client-id");
CheckoutRequest req = new CheckoutRequest(
BigDecimal.valueOf(1000), "XOF",
"+2250700000001", "user@test.com",
"OM-REF-002", "http://success", "http://cancel", Map.of());
assertThatThrownBy(() -> provider.initiateCheckout(req))
.isInstanceOf(PaymentException.class)
.hasMessageContaining("501");
}
@Test
void getStatus_returnsPROCESSING() throws Exception {
PaymentStatus status = provider.getStatus("OM-EXT-123");
assertThat(status).isEqualTo(PaymentStatus.PROCESSING);
}
@Test
void processWebhook_throwsNotImplemented() {
assertThatThrownBy(() -> provider.processWebhook("{}", Map.of()))
.isInstanceOf(PaymentException.class)
.hasMessageContaining("501");
}
@Test
void isAvailable_whenClientIdEmpty_returnsFalse() {
assertThat(provider.isAvailable()).isFalse();
}
@Test
void isAvailable_whenClientIdBlank_returnsFalse() throws Exception {
Field f = OrangeMoneyPaymentProvider.class.getDeclaredField("clientId");
f.setAccessible(true);
f.set(provider, " ");
assertThat(provider.isAvailable()).isFalse();
}
@Test
void isAvailable_whenClientIdSet_returnsTrue() throws Exception {
Field f = OrangeMoneyPaymentProvider.class.getDeclaredField("clientId");
f.setAccessible(true);
f.set(provider, "real-client-id");
assertThat(provider.isAvailable()).isTrue();
}
@Test
void isAvailable_whenClientIdNull_returnsFalse() throws Exception {
Field f = OrangeMoneyPaymentProvider.class.getDeclaredField("clientId");
f.setAccessible(true);
f.set(provider, null);
assertThat(provider.isAvailable()).isFalse();
}
@Test
void initiateCheckout_whenClientIdNull_returnsMockSession() throws Exception {
Field f = OrangeMoneyPaymentProvider.class.getDeclaredField("clientId");
f.setAccessible(true);
f.set(provider, null);
CheckoutRequest req = new CheckoutRequest(
BigDecimal.valueOf(500), "XOF",
"+2250700000002", "null@test.com",
"OM-NULL", "http://success", "http://cancel", Map.of());
CheckoutSession session = provider.initiateCheckout(req);
assertThat(session.externalId()).startsWith("OM-MOCK-");
}
}

View File

@@ -0,0 +1,185 @@
package dev.lions.unionflow.server.payment.orchestration;
import dev.lions.unionflow.server.api.payment.CheckoutRequest;
import dev.lions.unionflow.server.api.payment.CheckoutSession;
import dev.lions.unionflow.server.api.payment.PaymentException;
import dev.lions.unionflow.server.api.payment.PaymentProvider;
import dev.lions.unionflow.server.service.PaiementService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.lang.reflect.Field;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.Map;
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.Mockito.mock;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class PaymentOrchestratorTest {
@InjectMocks
PaymentOrchestrator orchestrator;
@Mock
PaymentProviderRegistry registry;
@Mock
PaiementService paiementService;
private CheckoutRequest sampleRequest;
@BeforeEach
void setUp() throws Exception {
Field defaultProviderField = PaymentOrchestrator.class.getDeclaredField("defaultProvider");
defaultProviderField.setAccessible(true);
defaultProviderField.set(orchestrator, "WAVE");
Field pispiField = PaymentOrchestrator.class.getDeclaredField("pispiPriority");
pispiField.setAccessible(true);
pispiField.set(orchestrator, false);
sampleRequest = new CheckoutRequest(
BigDecimal.valueOf(5000), "XOF",
"+2210100000000", "test@test.com",
"REF-TEST-001", "http://success", "http://cancel", Map.of());
}
@Test
void initierPaiement_usesRequestedProvider() throws Exception {
PaymentProvider waveProvider = mock(PaymentProvider.class);
when(waveProvider.isAvailable()).thenReturn(true);
CheckoutSession expectedSession = new CheckoutSession("EXT-001", "https://wave.pay/001", Instant.now().plusSeconds(3600), Map.of());
when(waveProvider.initiateCheckout(any())).thenReturn(expectedSession);
when(registry.get("WAVE")).thenReturn(waveProvider);
CheckoutSession session = orchestrator.initierPaiement(sampleRequest, "WAVE");
assertThat(session.externalId()).isEqualTo("EXT-001");
}
@Test
void initierPaiement_fallsBackToDefault_whenRequestedUnavailable() throws Exception {
PaymentProvider unavailableProvider = mock(PaymentProvider.class);
when(unavailableProvider.isAvailable()).thenReturn(false);
PaymentProvider waveProvider = mock(PaymentProvider.class);
when(waveProvider.isAvailable()).thenReturn(true);
CheckoutSession expectedSession = new CheckoutSession("WAVE-EXT-001", "https://wave.pay/001", Instant.now().plusSeconds(3600), Map.of());
when(waveProvider.initiateCheckout(any())).thenReturn(expectedSession);
when(registry.get("MOMO")).thenReturn(unavailableProvider);
when(registry.get("WAVE")).thenReturn(waveProvider);
CheckoutSession session = orchestrator.initierPaiement(sampleRequest, "MOMO");
assertThat(session.externalId()).isEqualTo("WAVE-EXT-001");
}
@Test
void initierPaiement_throwsWhenNoProviderAvailable() throws Exception {
when(registry.get(any())).thenThrow(new UnsupportedOperationException("Provider non supporté"));
assertThatThrownBy(() -> orchestrator.initierPaiement(sampleRequest, "UNKNOWN"))
.isInstanceOf(PaymentException.class);
}
@Test
void initierPaiement_withPispiPriority_triesPispiFirst() throws Exception {
Field pispiField = PaymentOrchestrator.class.getDeclaredField("pispiPriority");
pispiField.setAccessible(true);
pispiField.set(orchestrator, true);
PaymentProvider pispiProvider = mock(PaymentProvider.class);
when(pispiProvider.isAvailable()).thenReturn(true);
CheckoutSession expectedSession = new CheckoutSession("PISPI-EXT-001", "https://pispi.bceao/001", Instant.now().plusSeconds(3600), Map.of());
when(pispiProvider.initiateCheckout(any())).thenReturn(expectedSession);
when(registry.get("PISPI")).thenReturn(pispiProvider);
CheckoutSession session = orchestrator.initierPaiement(sampleRequest, "WAVE");
assertThat(session.externalId()).isEqualTo("PISPI-EXT-001");
}
@Test
void initierPaiement_withPispiPriority_noRequestedProvider_triesPispiThenDefault() throws Exception {
Field pispiField = PaymentOrchestrator.class.getDeclaredField("pispiPriority");
pispiField.setAccessible(true);
pispiField.set(orchestrator, true);
PaymentProvider pispiProvider = mock(PaymentProvider.class);
when(pispiProvider.isAvailable()).thenReturn(false); // PISPI unavailable
PaymentProvider waveProvider = mock(PaymentProvider.class);
when(waveProvider.isAvailable()).thenReturn(true);
CheckoutSession expectedSession = new CheckoutSession("WAVE-FALLBACK", "https://wave.pay/fallback", Instant.now().plusSeconds(3600), Map.of());
when(waveProvider.initiateCheckout(any())).thenReturn(expectedSession);
when(registry.get("PISPI")).thenReturn(pispiProvider);
when(registry.get("WAVE")).thenReturn(waveProvider);
CheckoutSession session = orchestrator.initierPaiement(sampleRequest, null);
assertThat(session.externalId()).isEqualTo("WAVE-FALLBACK");
}
@Test
void initierPaiement_noRequestedProvider_usesDefault() throws Exception {
PaymentProvider waveProvider = mock(PaymentProvider.class);
when(waveProvider.isAvailable()).thenReturn(true);
CheckoutSession expectedSession = new CheckoutSession("WAVE-DEFAULT", "https://wave.pay/default", Instant.now().plusSeconds(3600), Map.of());
when(waveProvider.initiateCheckout(any())).thenReturn(expectedSession);
when(registry.get("WAVE")).thenReturn(waveProvider);
CheckoutSession session = orchestrator.initierPaiement(sampleRequest, null);
assertThat(session.externalId()).isEqualTo("WAVE-DEFAULT");
}
@Test
void initierPaiement_whenProviderThrowsPaymentException_fallsBackToDefault() throws Exception {
PaymentProvider requestedProvider = mock(PaymentProvider.class);
when(requestedProvider.isAvailable()).thenReturn(true);
when(requestedProvider.initiateCheckout(any())).thenThrow(new PaymentException("MOMO", "Erreur MOMO", 503));
PaymentProvider waveProvider = mock(PaymentProvider.class);
when(waveProvider.isAvailable()).thenReturn(true);
CheckoutSession fallbackSession = new CheckoutSession("WAVE-AFTER-FAIL", "https://wave.pay/after-fail", Instant.now().plusSeconds(3600), Map.of());
when(waveProvider.initiateCheckout(any())).thenReturn(fallbackSession);
when(registry.get("MOMO")).thenReturn(requestedProvider);
when(registry.get("WAVE")).thenReturn(waveProvider);
CheckoutSession session = orchestrator.initierPaiement(sampleRequest, "MOMO");
assertThat(session.externalId()).isEqualTo("WAVE-AFTER-FAIL");
}
@Test
void initierPaiement_allProvidersThrow_throwsLastException() throws Exception {
PaymentProvider requestedProvider = mock(PaymentProvider.class);
when(requestedProvider.isAvailable()).thenReturn(true);
when(requestedProvider.initiateCheckout(any())).thenThrow(new PaymentException("MOMO", "Erreur MOMO", 503));
PaymentProvider waveProvider = mock(PaymentProvider.class);
when(waveProvider.isAvailable()).thenReturn(true);
when(waveProvider.initiateCheckout(any())).thenThrow(new PaymentException("WAVE", "Erreur WAVE", 503));
when(registry.get("MOMO")).thenReturn(requestedProvider);
when(registry.get("WAVE")).thenReturn(waveProvider);
assertThatThrownBy(() -> orchestrator.initierPaiement(sampleRequest, "MOMO"))
.isInstanceOf(PaymentException.class)
.hasMessageContaining("WAVE");
}
}

View File

@@ -0,0 +1,76 @@
package dev.lions.unionflow.server.payment.orchestration;
import dev.lions.unionflow.server.api.payment.PaymentProvider;
import jakarta.enterprise.inject.Instance;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Field;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class PaymentProviderRegistryTest {
private PaymentProviderRegistry registry;
private PaymentProvider waveProvider;
private PaymentProvider momoProvider;
@BeforeEach
@SuppressWarnings("unchecked")
void setUp() throws Exception {
registry = new PaymentProviderRegistry();
waveProvider = mock(PaymentProvider.class);
when(waveProvider.getProviderCode()).thenReturn("WAVE");
momoProvider = mock(PaymentProvider.class);
when(momoProvider.getProviderCode()).thenReturn("MTN_MOMO");
// Use thenAnswer so spliterator() returns a fresh Spliterator each call
// (spliterators are single-use)
Instance<PaymentProvider> instance = mock(Instance.class);
List<PaymentProvider> providerList = List.of(waveProvider, momoProvider);
when(instance.spliterator()).thenAnswer(inv -> providerList.spliterator());
Field f = PaymentProviderRegistry.class.getDeclaredField("providers");
f.setAccessible(true);
f.set(registry, instance);
}
@Test
void get_returnsMatchingProvider() {
assertThat(registry.get("WAVE")).isEqualTo(waveProvider);
assertThat(registry.get("MTN_MOMO")).isEqualTo(momoProvider);
}
@Test
void get_isCaseInsensitive() {
assertThat(registry.get("wave")).isEqualTo(waveProvider);
assertThat(registry.get("mtn_momo")).isEqualTo(momoProvider);
assertThat(registry.get("Wave")).isEqualTo(waveProvider);
}
@Test
void get_unknownCode_throwsUnsupportedOperationException() {
assertThatThrownBy(() -> registry.get("UNKNOWN_PROVIDER"))
.isInstanceOf(UnsupportedOperationException.class)
.hasMessageContaining("UNKNOWN_PROVIDER");
}
@Test
void getAll_returnsAllProviders() {
List<PaymentProvider> all = registry.getAll();
assertThat(all).hasSize(2);
assertThat(all).contains(waveProvider, momoProvider);
}
@Test
void getAvailableCodes_returnsAllCodes() {
List<String> codes = registry.getAvailableCodes();
assertThat(codes).contains("WAVE", "MTN_MOMO");
}
}

View File

@@ -0,0 +1,81 @@
package dev.lions.unionflow.server.payment.pispi;
import dev.lions.unionflow.server.payment.pispi.dto.Pacs002Response;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class Pacs002ResponseTest {
private static final String SAMPLE_XML = """
<?xml version="1.0" encoding="UTF-8"?>
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:pacs.002.001.14">
<FIToFIPmtStsRpt>
<TxInfAndSts>
<OrgnlEndToEndId>REF-001</OrgnlEndToEndId>
<TxSts>ACSC</TxSts>
<ClrSysRef>BCEAO-12345</ClrSysRef>
</TxInfAndSts>
</FIToFIPmtStsRpt>
</Document>
""";
@Test
@DisplayName("fromXml parse le statut de transaction")
void fromXml_parsesTransactionStatus() {
Pacs002Response resp = Pacs002Response.fromXml(SAMPLE_XML);
assertThat(resp.getTransactionStatus()).isEqualTo("ACSC");
}
@Test
@DisplayName("fromXml parse le originalEndToEndId")
void fromXml_parsesOriginalEndToEndId() {
Pacs002Response resp = Pacs002Response.fromXml(SAMPLE_XML);
assertThat(resp.getOriginalEndToEndId()).isEqualTo("REF-001");
}
@Test
@DisplayName("fromXml parse le clearingSystemReference")
void fromXml_parsesClearingSystemReference() {
Pacs002Response resp = Pacs002Response.fromXml(SAMPLE_XML);
assertThat(resp.getClearingSystemReference()).isEqualTo("BCEAO-12345");
}
@Test
@DisplayName("fromXml retourne null pour les champs absents")
void fromXml_returnsNullForMissingFields() {
Pacs002Response resp = Pacs002Response.fromXml(SAMPLE_XML);
assertThat(resp.getRejectReasonCode()).isNull();
assertThat(resp.getAcceptanceDateTime()).isNull();
}
@Test
@DisplayName("fromXml parse le rejectReasonCode quand présent")
void fromXml_parsesRejectReasonCode() {
String xml = """
<?xml version="1.0" encoding="UTF-8"?>
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:pacs.002.001.14">
<FIToFIPmtStsRpt>
<TxInfAndSts>
<OrgnlEndToEndId>REF-002</OrgnlEndToEndId>
<TxSts>RJCT</TxSts>
<RsnCd>AC01</RsnCd>
</TxInfAndSts>
</FIToFIPmtStsRpt>
</Document>
""";
Pacs002Response resp = Pacs002Response.fromXml(xml);
assertThat(resp.getTransactionStatus()).isEqualTo("RJCT");
assertThat(resp.getRejectReasonCode()).isEqualTo("AC01");
}
@Test
@DisplayName("fromXml lève IllegalArgumentException si XML invalide")
void fromXml_throwsOnInvalidXml() {
assertThatThrownBy(() -> Pacs002Response.fromXml("not xml at all"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("pacs.002");
}
}

View File

@@ -0,0 +1,71 @@
package dev.lions.unionflow.server.payment.pispi;
import dev.lions.unionflow.server.payment.pispi.dto.Pacs008Request;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import static org.assertj.core.api.Assertions.assertThat;
class Pacs008RequestTest {
private Pacs008Request request;
@BeforeEach
void setUp() {
request = new Pacs008Request();
request.setMessageId("UFMSG-ABC123456789");
request.setCreationDateTime("2026-04-20T10:00:00Z");
request.setNumberOfTransactions("1");
request.setEndToEndId("REF-SOUSCRIPTION-001");
request.setInstrId("UFINS-12345678");
request.setAmount(new BigDecimal("5000.00"));
request.setCurrency("XOF");
request.setDebtorName("Jean Dupont");
request.setDebtorBic("BCEAOCIAB");
request.setCreditorName("Mutuelle Solidarité");
request.setCreditorBic("BCEAOCIAB");
request.setRemittanceInfo("Cotisation mensuelle");
}
@Test
@DisplayName("toXml contient le messageId")
void toXml_containsMessageId() {
String xml = request.toXml();
assertThat(xml).contains("UFMSG-ABC123456789");
assertThat(xml).contains("<MsgId>UFMSG-ABC123456789</MsgId>");
}
@Test
@DisplayName("toXml contient le montant")
void toXml_containsAmount() {
String xml = request.toXml();
assertThat(xml).contains("5000.00");
assertThat(xml).contains("IntrBkSttlmAmt");
}
@Test
@DisplayName("toXml contient le namespace ISO 20022 pacs.008")
void toXml_containsIso20022Namespace() {
String xml = request.toXml();
assertThat(xml).contains("urn:iso:std:iso:20022:tech:xsd:pacs.008.001.10");
}
@Test
@DisplayName("toXml contient le endToEndId")
void toXml_containsEndToEndId() {
String xml = request.toXml();
assertThat(xml).contains("<EndToEndId>REF-SOUSCRIPTION-001</EndToEndId>");
}
@Test
@DisplayName("toXml échappe les caractères XML spéciaux dans les champs texte")
void toXml_escapesSpecialCharacters() {
request.setDebtorName("Company & Co <Tag>");
String xml = request.toXml();
assertThat(xml).contains("Company &amp; Co &lt;Tag&gt;");
assertThat(xml).doesNotContain("<Tag>");
}
}

View File

@@ -0,0 +1,91 @@
package dev.lions.unionflow.server.payment.pispi;
import dev.lions.unionflow.server.api.payment.PaymentException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Field;
import java.time.Instant;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class PispiAuthTest {
private PispiAuth auth;
@BeforeEach
void setUp() {
auth = new PispiAuth();
// clientId and clientSecret default to ""
}
@Test
void getAccessToken_whenCacheValid_returnsCachedToken() throws Exception {
Field tokenField = PispiAuth.class.getDeclaredField("cachedToken");
Field expiryField = PispiAuth.class.getDeclaredField("cacheExpiry");
tokenField.setAccessible(true);
expiryField.setAccessible(true);
tokenField.set(auth, "mock-token-123");
expiryField.set(auth, Instant.now().plusSeconds(300));
String token = auth.getAccessToken();
assertThat(token).isEqualTo("mock-token-123");
}
@Test
void getAccessToken_whenCacheExpired_attemptsNewToken() throws Exception {
Field tokenField = PispiAuth.class.getDeclaredField("cachedToken");
Field expiryField = PispiAuth.class.getDeclaredField("cacheExpiry");
tokenField.setAccessible(true);
expiryField.setAccessible(true);
tokenField.set(auth, "old-token");
expiryField.set(auth, Instant.now().minusSeconds(60)); // expired
// With empty clientId/clientSecret, the HTTP call will fail
// (connection refused or malformed URL) — wrapped as PaymentException
assertThatThrownBy(() -> auth.getAccessToken())
.isInstanceOf(PaymentException.class);
}
@Test
void getAccessToken_whenNoCache_andCredentialsEmpty_throwsPaymentException() {
// cachedToken is null (default), so it tries HTTP call which will fail
assertThatThrownBy(() -> auth.getAccessToken())
.isInstanceOf(PaymentException.class);
}
@Test
void getAccessToken_whenCacheIsNullButExpiryFuture_attemptsNewToken() throws Exception {
Field tokenField = PispiAuth.class.getDeclaredField("cachedToken");
Field expiryField = PispiAuth.class.getDeclaredField("cacheExpiry");
tokenField.setAccessible(true);
expiryField.setAccessible(true);
tokenField.set(auth, null); // null token
expiryField.set(auth, Instant.now().plusSeconds(300));
// null cachedToken means the condition `cachedToken != null` fails — goes to HTTP
assertThatThrownBy(() -> auth.getAccessToken())
.isInstanceOf(PaymentException.class);
}
@Test
void getAccessToken_whenBaseUrlInvalid_throwsPaymentException() throws Exception {
Field baseUrlField = PispiAuth.class.getDeclaredField("baseUrl");
baseUrlField.setAccessible(true);
baseUrlField.set(auth, "http://localhost:1"); // unreachable
Field clientIdField = PispiAuth.class.getDeclaredField("clientId");
clientIdField.setAccessible(true);
clientIdField.set(auth, "test-client");
Field clientSecretField = PispiAuth.class.getDeclaredField("clientSecret");
clientSecretField.setAccessible(true);
clientSecretField.set(auth, "test-secret");
assertThatThrownBy(() -> auth.getAccessToken())
.isInstanceOf(PaymentException.class)
.hasMessageContaining("OAuth2");
}
}

View File

@@ -0,0 +1,89 @@
package dev.lions.unionflow.server.payment.pispi;
import dev.lions.unionflow.server.api.payment.PaymentException;
import dev.lions.unionflow.server.payment.pispi.dto.Pacs008Request;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.lang.reflect.Field;
import java.math.BigDecimal;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class PispiClientTest {
@Mock
PispiAuth pispiAuth;
private PispiClient client;
@BeforeEach
void setUp() throws Exception {
client = new PispiClient();
Field authField = PispiClient.class.getDeclaredField("pispiAuth");
authField.setAccessible(true);
authField.set(client, pispiAuth);
Field baseUrlField = PispiClient.class.getDeclaredField("baseUrl");
baseUrlField.setAccessible(true);
baseUrlField.set(client, "http://localhost:1"); // unreachable endpoint
Field institutionField = PispiClient.class.getDeclaredField("institutionCode");
institutionField.setAccessible(true);
institutionField.set(client, "TEST-BIC");
}
@Test
void initiatePayment_whenAuthFails_throwsPaymentException() throws Exception {
when(pispiAuth.getAccessToken()).thenThrow(
new PaymentException("PISPI", "OAuth2 failed", 503));
Pacs008Request request = new Pacs008Request();
request.setEndToEndId("E2E-001");
request.setAmount(BigDecimal.valueOf(5000));
request.setCurrency("XOF");
assertThatThrownBy(() -> client.initiatePayment(request))
.isInstanceOf(PaymentException.class);
}
@Test
void initiatePayment_whenHttpCallFails_throwsPaymentException() throws Exception {
when(pispiAuth.getAccessToken()).thenReturn("mock-token");
Pacs008Request request = new Pacs008Request();
request.setEndToEndId("E2E-002");
request.setAmount(BigDecimal.valueOf(1000));
request.setCurrency("XOF");
// http://localhost:1 will refuse connection → wrapped as PaymentException
assertThatThrownBy(() -> client.initiatePayment(request))
.isInstanceOf(PaymentException.class)
.hasMessageContaining("PI-SPI");
}
@Test
void getStatus_whenAuthFails_throwsPaymentException() throws Exception {
when(pispiAuth.getAccessToken()).thenThrow(
new PaymentException("PISPI", "OAuth2 failed", 503));
assertThatThrownBy(() -> client.getStatus("TXN-001"))
.isInstanceOf(PaymentException.class);
}
@Test
void getStatus_whenHttpCallFails_throwsPaymentException() throws Exception {
when(pispiAuth.getAccessToken()).thenReturn("mock-token");
// http://localhost:1 will refuse connection → wrapped as PaymentException
assertThatThrownBy(() -> client.getStatus("TXN-002"))
.isInstanceOf(PaymentException.class)
.hasMessageContaining("PI-SPI");
}
}

View File

@@ -0,0 +1,117 @@
package dev.lions.unionflow.server.payment.pispi;
import dev.lions.unionflow.server.api.payment.CheckoutRequest;
import dev.lions.unionflow.server.api.payment.PaymentEvent;
import dev.lions.unionflow.server.api.payment.PaymentStatus;
import dev.lions.unionflow.server.payment.pispi.dto.Pacs002Response;
import dev.lions.unionflow.server.payment.pispi.dto.Pacs008Request;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
class PispiIso20022MapperTest {
private PispiIso20022Mapper mapper;
@BeforeEach
void setUp() {
mapper = new PispiIso20022Mapper();
}
private CheckoutRequest buildRequest(String reference) {
return new CheckoutRequest(
new BigDecimal("5000"),
"XOF",
"+2250700000001",
"user@test.ci",
reference,
"https://ok.ci",
"https://cancel.ci",
Map.of("customerName", "Kofi Mensah")
);
}
@Test
@DisplayName("toPacs008 : endToEndId égal à la référence courte")
void toPacs008_setsEndToEndIdFromReference() {
CheckoutRequest req = buildRequest("REF-2026-001");
Pacs008Request pacs = mapper.toPacs008(req, "BCEAOCIAB");
assertThat(pacs.getEndToEndId()).isEqualTo("REF-2026-001");
}
@Test
@DisplayName("toPacs008 : référence de 40 chars tronquée à 35")
void toPacs008_truncatesLongReference() {
String longRef = "A".repeat(40);
CheckoutRequest req = buildRequest(longRef);
Pacs008Request pacs = mapper.toPacs008(req, "BCEAOCIAB");
assertThat(pacs.getEndToEndId()).hasSize(35);
assertThat(pacs.getEndToEndId()).isEqualTo("A".repeat(35));
}
@Test
@DisplayName("fromPacs002Status ACSC → SUCCESS")
void fromPacs002Status_ACSC_returnsSuccess() {
assertThat(mapper.fromPacs002Status("ACSC")).isEqualTo(PaymentStatus.SUCCESS);
}
@Test
@DisplayName("fromPacs002Status ACSP → PROCESSING")
void fromPacs002Status_ACSP_returnsProcessing() {
assertThat(mapper.fromPacs002Status("ACSP")).isEqualTo(PaymentStatus.PROCESSING);
}
@Test
@DisplayName("fromPacs002Status RJCT → FAILED")
void fromPacs002Status_RJCT_returnsFailed() {
assertThat(mapper.fromPacs002Status("RJCT")).isEqualTo(PaymentStatus.FAILED);
}
@Test
@DisplayName("fromPacs002Status code inconnu → PROCESSING")
void fromPacs002Status_unknown_returnsProcessing() {
assertThat(mapper.fromPacs002Status("XXXX")).isEqualTo(PaymentStatus.PROCESSING);
}
@Test
@DisplayName("fromPacs002 construit le PaymentEvent correctement")
void fromPacs002_buildsEventCorrectly() {
Pacs002Response resp = new Pacs002Response();
resp.setClearingSystemReference("BCEAO-99999");
resp.setOriginalEndToEndId("REF-SOUSCRIPTION-007");
resp.setTransactionStatus("ACSC");
Instant ts = Instant.parse("2026-04-20T12:00:00Z");
resp.setAcceptanceDateTime(ts);
PaymentEvent event = mapper.fromPacs002(resp);
assertThat(event.externalId()).isEqualTo("BCEAO-99999");
assertThat(event.reference()).isEqualTo("REF-SOUSCRIPTION-007");
assertThat(event.status()).isEqualTo(PaymentStatus.SUCCESS);
assertThat(event.amountConfirmed()).isNull();
assertThat(event.transactionCode()).isEqualTo("BCEAO-99999");
assertThat(event.occurredAt()).isEqualTo(ts);
}
@Test
@DisplayName("fromPacs002 utilise Instant.now() quand acceptanceDateTime est null")
void fromPacs002_usesNowWhenAcceptanceDateTimeNull() {
Pacs002Response resp = new Pacs002Response();
resp.setClearingSystemReference("REF");
resp.setOriginalEndToEndId("E2E");
resp.setTransactionStatus("PDNG");
resp.setAcceptanceDateTime(null);
Instant before = Instant.now();
PaymentEvent event = mapper.fromPacs002(resp);
Instant after = Instant.now();
assertThat(event.occurredAt()).isBetween(before, after);
}
}

View File

@@ -0,0 +1,80 @@
package dev.lions.unionflow.server.payment.pispi;
import dev.lions.unionflow.server.api.payment.CheckoutRequest;
import dev.lions.unionflow.server.api.payment.CheckoutSession;
import dev.lions.unionflow.server.api.payment.PaymentException;
import dev.lions.unionflow.server.api.payment.PaymentStatus;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Field;
import java.math.BigDecimal;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class PispiPaymentProviderTest {
private PispiPaymentProvider provider;
@BeforeEach
void setUp() throws Exception {
provider = new PispiPaymentProvider();
setField("clientId", "");
setField("institutionCode", "");
setField("institutionBic", "");
// pispiClient et mapper non injectés → null, mais isConfigured() retourne false donc non appelés
}
private void setField(String name, String value) throws Exception {
Field f = PispiPaymentProvider.class.getDeclaredField(name);
f.setAccessible(true);
f.set(provider, value);
}
@Test
@DisplayName("getProviderCode retourne PISPI")
void getProviderCode_returnsPISPI() {
assertThat(provider.getProviderCode()).isEqualTo("PISPI");
}
@Test
@DisplayName("isAvailable retourne false si non configuré")
void isAvailable_whenNotConfigured_returnsFalse() {
assertThat(provider.isAvailable()).isFalse();
}
@Test
@DisplayName("initiateCheckout retourne une session mock si non configuré")
void initiateCheckout_whenNotConfigured_returnsMockSession() throws Exception {
CheckoutRequest req = new CheckoutRequest(
new BigDecimal("10000"), "XOF",
"+2250700000001", "user@test.ci",
"SOUSCRIPTION-001", "https://ok", "https://cancel",
Map.of()
);
CheckoutSession session = provider.initiateCheckout(req);
assertThat(session.externalId()).startsWith("PISPI-MOCK-");
assertThat(session.checkoutUrl()).startsWith("https://mock.pispi.bceao.int/pay/");
assertThat(session.providerMetadata()).containsEntry("mock", "true");
assertThat(session.providerMetadata()).containsEntry("provider", "PISPI");
}
@Test
@DisplayName("getStatus retourne PROCESSING si non configuré")
void getStatus_whenNotConfigured_returnsProcessing() throws Exception {
assertThat(provider.getStatus("ANY-ID")).isEqualTo(PaymentStatus.PROCESSING);
}
@Test
@DisplayName("processWebhook lève PaymentException — déléguer à /api/pispi/webhook")
void processWebhook_throwsPaymentException() {
assertThatThrownBy(() -> provider.processWebhook("body", Map.of()))
.isInstanceOf(PaymentException.class)
.hasMessageContaining("pispi/webhook")
.extracting(e -> ((PaymentException) e).getHttpStatus())
.isEqualTo(400);
}
}

View File

@@ -0,0 +1,106 @@
package dev.lions.unionflow.server.payment.pispi;
import dev.lions.unionflow.server.api.payment.PaymentException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.lang.reflect.Field;
import java.util.HexFormat;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class PispiSignatureVerifierTest {
private PispiSignatureVerifier verifier;
@BeforeEach
void setUp() {
verifier = new PispiSignatureVerifier();
}
private void setField(String fieldName, String value) throws Exception {
Field f = PispiSignatureVerifier.class.getDeclaredField(fieldName);
f.setAccessible(true);
f.set(verifier, value);
}
@Test
@DisplayName("isIpAllowed — pas de config → true")
void isIpAllowed_whenNoConfig_returnsTrue() throws Exception {
setField("allowedIps", "");
assertThat(verifier.isIpAllowed("1.2.3.4")).isTrue();
}
@Test
@DisplayName("isIpAllowed — IP dans la liste → true")
void isIpAllowed_whenIpInList_returnsTrue() throws Exception {
setField("allowedIps", "10.0.0.1, 10.0.0.2, 192.168.1.1");
assertThat(verifier.isIpAllowed("10.0.0.2")).isTrue();
}
@Test
@DisplayName("isIpAllowed — IP absente de la liste → false")
void isIpAllowed_whenIpNotInList_returnsFalse() throws Exception {
setField("allowedIps", "10.0.0.1,10.0.0.2");
assertThat(verifier.isIpAllowed("1.2.3.4")).isFalse();
}
@Test
@DisplayName("verifySignature — pas de secret configuré → true")
void verifySignature_whenNoSecret_returnsTrue() throws Exception {
setField("webhookSecret", "");
assertThat(verifier.verifySignature("body", Map.of())).isTrue();
}
@Test
@DisplayName("verifySignature — header absent → PaymentException 401")
void verifySignature_whenSignatureAbsent_throwsPaymentException() throws Exception {
setField("webhookSecret", "secret123");
assertThatThrownBy(() -> verifier.verifySignature("body", Map.of()))
.isInstanceOf(PaymentException.class)
.hasMessageContaining("absente")
.extracting(e -> ((PaymentException) e).getHttpStatus())
.isEqualTo(401);
}
@Test
@DisplayName("verifySignature — signature correcte → true")
void verifySignature_whenSignatureValid_returnsTrue() throws Exception {
setField("webhookSecret", "secret123");
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec("secret123".getBytes(), "HmacSHA256"));
String validSig = HexFormat.of().formatHex(mac.doFinal("body".getBytes()));
assertThat(verifier.verifySignature("body", Map.of("X-PISPI-Signature", validSig))).isTrue();
}
@Test
@DisplayName("verifySignature — signature incorrecte → PaymentException 401")
void verifySignature_whenSignatureInvalid_throwsPaymentException() throws Exception {
setField("webhookSecret", "secret123");
assertThatThrownBy(() -> verifier.verifySignature("body", Map.of("X-PISPI-Signature", "deadbeef")))
.isInstanceOf(PaymentException.class)
.hasMessageContaining("invalide")
.extracting(e -> ((PaymentException) e).getHttpStatus())
.isEqualTo(401);
}
@Test
@DisplayName("verifySignature — header insensible à la casse")
void verifySignature_caseInsensitiveHeader() throws Exception {
setField("webhookSecret", "secret123");
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec("secret123".getBytes(), "HmacSHA256"));
String validSig = HexFormat.of().formatHex(mac.doFinal("body".getBytes()));
// header en minuscules
assertThat(verifier.verifySignature("body", Map.of("x-pispi-signature", validSig))).isTrue();
}
}

View File

@@ -0,0 +1,174 @@
package dev.lions.unionflow.server.payment.pispi;
import dev.lions.unionflow.server.api.payment.PaymentEvent;
import dev.lions.unionflow.server.api.payment.PaymentException;
import dev.lions.unionflow.server.api.payment.PaymentStatus;
import dev.lions.unionflow.server.payment.orchestration.PaymentOrchestrator;
import dev.lions.unionflow.server.payment.pispi.dto.Pacs002Response;
import jakarta.ws.rs.core.HttpHeaders;
import jakarta.ws.rs.core.MultivaluedHashMap;
import jakarta.ws.rs.core.MultivaluedMap;
import jakarta.ws.rs.core.Response;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import org.mockito.junit.jupiter.MockitoExtension;
import java.time.Instant;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class PispiWebhookResourceTest {
@InjectMocks
PispiWebhookResource resource;
@Mock
PispiSignatureVerifier verifier;
@Mock
PispiIso20022Mapper mapper;
@Mock
PaymentOrchestrator orchestrator;
@Mock
HttpHeaders headers;
private static final String VALID_XML =
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>" +
"<FIToFIPmtStsRpt>" +
" <OrgnlEndToEndId>REF-001</OrgnlEndToEndId>" +
" <TxSts>ACSC</TxSts>" +
" <ClrSysRef>PISPI-TXN-001</ClrSysRef>" +
"</FIToFIPmtStsRpt>";
@BeforeEach
void setUp() {
// Default: IP is allowed
when(verifier.isIpAllowed(anyString())).thenReturn(true);
}
@Test
void recevoir_whenIpNotAllowed_returns403() {
when(verifier.isIpAllowed("192.168.1.100")).thenReturn(false);
Response response = resource.recevoir(VALID_XML, headers, "192.168.1.100");
assertThat(response.getStatus()).isEqualTo(403);
}
@Test
void recevoir_whenIpAllowedAndSignatureInvalid_returns401() throws Exception {
MultivaluedMap<String, String> headersMap = new MultivaluedHashMap<>();
headersMap.put("X-PISPI-Signature", List.of("invalidsig"));
when(headers.getRequestHeaders()).thenReturn(headersMap);
doThrow(new PaymentException("PISPI", "Signature invalide", 401))
.when(verifier).verifySignature(anyString(), any());
Response response = resource.recevoir(VALID_XML, headers, "");
assertThat(response.getStatus()).isEqualTo(401);
}
@Test
void recevoir_whenValid_returns200AndCallsOrchestrator() throws Exception {
MultivaluedMap<String, String> headersMap = new MultivaluedHashMap<>();
when(headers.getRequestHeaders()).thenReturn(headersMap);
when(verifier.verifySignature(anyString(), any())).thenReturn(true);
Pacs002Response pacs002 = new Pacs002Response();
pacs002.setOriginalEndToEndId("REF-001");
pacs002.setTransactionStatus("ACSC");
pacs002.setClearingSystemReference("PISPI-TXN-001");
PaymentEvent event = new PaymentEvent("PISPI-TXN-001", "REF-001", PaymentStatus.SUCCESS,
null, "PISPI-TXN-001", Instant.now());
try (MockedStatic<Pacs002Response> mockedStatic = mockStatic(Pacs002Response.class)) {
mockedStatic.when(() -> Pacs002Response.fromXml(VALID_XML)).thenReturn(pacs002);
when(mapper.fromPacs002(pacs002)).thenReturn(event);
doNothing().when(orchestrator).handleEvent(event);
Response response = resource.recevoir(VALID_XML, headers, "");
assertThat(response.getStatus()).isEqualTo(200);
verify(orchestrator).handleEvent(event);
}
}
@Test
void recevoir_whenXmlInvalid_returns500() throws Exception {
MultivaluedMap<String, String> headersMap = new MultivaluedHashMap<>();
when(headers.getRequestHeaders()).thenReturn(headersMap);
when(verifier.verifySignature(anyString(), any())).thenReturn(true);
String invalidXml = "NOT VALID XML <<<";
Response response = resource.recevoir(invalidXml, headers, "");
assertThat(response.getStatus()).isEqualTo(500);
}
@Test
void recevoir_whenIpFromForwardedFor_extractsFirstIp() {
// Two IPs in X-Forwarded-For: first should be checked
when(verifier.isIpAllowed("10.0.0.1")).thenReturn(false);
Response response = resource.recevoir(VALID_XML, headers, "10.0.0.1, 172.16.0.1");
assertThat(response.getStatus()).isEqualTo(403);
}
@Test
void recevoir_whenForwardedForBlank_usesUnknown() {
// "unknown" IP should be allowed (default behavior with empty allowedIps)
when(verifier.isIpAllowed("unknown")).thenReturn(true);
MultivaluedMap<String, String> headersMap = new MultivaluedHashMap<>();
when(headers.getRequestHeaders()).thenReturn(headersMap);
when(verifier.verifySignature(anyString(), any())).thenReturn(true);
try (MockedStatic<Pacs002Response> mockedStatic = mockStatic(Pacs002Response.class)) {
Pacs002Response pacs002 = new Pacs002Response();
pacs002.setTransactionStatus("ACSC");
mockedStatic.when(() -> Pacs002Response.fromXml(anyString())).thenReturn(pacs002);
PaymentEvent event = new PaymentEvent("EXT", "REF", PaymentStatus.SUCCESS, null, "TXN", Instant.now());
when(mapper.fromPacs002(pacs002)).thenReturn(event);
// forwardedFor = "" (blank)
Response response = resource.recevoir(VALID_XML, headers, "");
assertThat(response.getStatus()).isEqualTo(200);
}
}
@Test
void recevoir_whenOrchestratorThrows_returns500() throws Exception {
MultivaluedMap<String, String> headersMap = new MultivaluedHashMap<>();
when(headers.getRequestHeaders()).thenReturn(headersMap);
when(verifier.verifySignature(anyString(), any())).thenReturn(true);
try (MockedStatic<Pacs002Response> mockedStatic = mockStatic(Pacs002Response.class)) {
Pacs002Response pacs002 = new Pacs002Response();
pacs002.setTransactionStatus("ACSC");
mockedStatic.when(() -> Pacs002Response.fromXml(VALID_XML)).thenReturn(pacs002);
PaymentEvent event = new PaymentEvent("EXT", "REF", PaymentStatus.SUCCESS, null, "TXN", Instant.now());
when(mapper.fromPacs002(pacs002)).thenReturn(event);
doThrow(new RuntimeException("Orchestrator error")).when(orchestrator).handleEvent(event);
Response response = resource.recevoir(VALID_XML, headers, "");
assertThat(response.getStatus()).isEqualTo(500);
}
}
}

View File

@@ -0,0 +1,232 @@
package dev.lions.unionflow.server.payment.wave;
import dev.lions.unionflow.server.api.payment.CheckoutRequest;
import dev.lions.unionflow.server.api.payment.CheckoutSession;
import dev.lions.unionflow.server.api.payment.PaymentEvent;
import dev.lions.unionflow.server.api.payment.PaymentException;
import dev.lions.unionflow.server.api.payment.PaymentStatus;
import dev.lions.unionflow.server.service.WaveCheckoutService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.lang.reflect.Field;
import java.math.BigDecimal;
import java.util.HexFormat;
import java.util.Map;
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.Mockito.when;
@ExtendWith(MockitoExtension.class)
class WavePaymentProviderTest {
@Mock
WaveCheckoutService waveCheckoutService;
private WavePaymentProvider provider;
@BeforeEach
void setUp() throws Exception {
provider = new WavePaymentProvider();
Field serviceField = WavePaymentProvider.class.getDeclaredField("waveCheckoutService");
serviceField.setAccessible(true);
serviceField.set(provider, waveCheckoutService);
// webhookSecret defaults to ""
}
@Test
void getProviderCode_returns_WAVE() {
assertThat(provider.getProviderCode()).isEqualTo("WAVE");
}
@Test
void code_constant_is_WAVE() {
assertThat(WavePaymentProvider.CODE).isEqualTo("WAVE");
}
@Test
void isAvailable_returnsTrue() {
// WavePaymentProvider uses the default implementation which always returns true
assertThat(provider.isAvailable()).isTrue();
}
@Test
void getStatus_returnsProcessing() throws Exception {
PaymentStatus status = provider.getStatus("WAVE-EXT-123");
assertThat(status).isEqualTo(PaymentStatus.PROCESSING);
}
@Test
void initiateCheckout_delegatesToWaveCheckoutService() throws Exception {
WaveCheckoutService.WaveCheckoutSessionResponse mockResp =
new WaveCheckoutService.WaveCheckoutSessionResponse("WAVE-SESSION-001", "https://pay.wave.com/c/WAVE-SESSION-001");
when(waveCheckoutService.createSession(any(), any(), any(), any(), any(), any()))
.thenReturn(mockResp);
CheckoutRequest req = new CheckoutRequest(
BigDecimal.valueOf(5000), "XOF",
"+2210100000000", "test@wave.com",
"REF-WAVE-001", "http://success", "http://cancel", Map.of());
CheckoutSession session = provider.initiateCheckout(req);
assertThat(session.externalId()).isEqualTo("WAVE-SESSION-001");
assertThat(session.checkoutUrl()).isEqualTo("https://pay.wave.com/c/WAVE-SESSION-001");
assertThat(session.expiresAt()).isNotNull();
assertThat(session.providerMetadata()).containsEntry("provider", "WAVE");
}
@Test
void initiateCheckout_whenServiceThrows_wrapsInPaymentException() throws Exception {
when(waveCheckoutService.createSession(any(), any(), any(), any(), any(), any()))
.thenThrow(new RuntimeException("Wave API error"));
CheckoutRequest req = new CheckoutRequest(
BigDecimal.valueOf(1000), "XOF",
"+2210100000001", "fail@wave.com",
"REF-FAIL", "http://success", "http://cancel", Map.of());
assertThatThrownBy(() -> provider.initiateCheckout(req))
.isInstanceOf(PaymentException.class)
.hasMessageContaining("WAVE");
}
@Test
void processWebhook_whenNoSecret_parsesCompletedEvent() throws Exception {
String json = "{\"type\":\"checkout.session.completed\",\"data\":{\"id\":\"WAVE-EXT-001\",\"client_reference\":\"REF-001\",\"amount\":\"5000\",\"transaction_id\":\"TXN-001\"}}";
PaymentEvent event = provider.processWebhook(json, Map.of());
assertThat(event.externalId()).isEqualTo("WAVE-EXT-001");
assertThat(event.reference()).isEqualTo("REF-001");
assertThat(event.status()).isEqualTo(PaymentStatus.SUCCESS);
assertThat(event.amountConfirmed()).isEqualByComparingTo(new BigDecimal("5000"));
assertThat(event.transactionCode()).isEqualTo("TXN-001");
}
@Test
void processWebhook_failedEvent_returnsFailed() throws Exception {
String json = "{\"type\":\"checkout.session.failed\",\"data\":{\"id\":\"WAVE-EXT-002\",\"client_reference\":\"REF-002\",\"amount\":\"1000\"}}";
PaymentEvent event = provider.processWebhook(json, Map.of());
assertThat(event.status()).isEqualTo(PaymentStatus.FAILED);
}
@Test
void processWebhook_expiredEvent_returnsExpired() throws Exception {
String json = "{\"type\":\"checkout.session.expired\",\"data\":{\"id\":\"WAVE-EXT-003\",\"client_reference\":\"REF-003\",\"amount\":\"2000\"}}";
PaymentEvent event = provider.processWebhook(json, Map.of());
assertThat(event.status()).isEqualTo(PaymentStatus.EXPIRED);
}
@Test
void processWebhook_unknownEvent_returnsProcessing() throws Exception {
String json = "{\"type\":\"some.unknown.event\",\"data\":{\"id\":\"WAVE-EXT-004\",\"client_reference\":\"REF-004\",\"amount\":\"500\"}}";
PaymentEvent event = provider.processWebhook(json, Map.of());
assertThat(event.status()).isEqualTo(PaymentStatus.PROCESSING);
}
@Test
void processWebhook_whenInvalidJson_throwsPaymentException() {
String notJson = "not-valid-json{{{";
assertThatThrownBy(() -> provider.processWebhook(notJson, Map.of()))
.isInstanceOf(PaymentException.class)
.hasMessageContaining("Wave");
}
@Test
void processWebhook_whenSignaturePresentButNoSecret_skipsVerification() throws Exception {
// webhookSecret is empty => no verification
String json = "{\"type\":\"checkout.session.completed\",\"data\":{\"id\":\"W-EXT-005\",\"client_reference\":\"REF-005\",\"amount\":\"100\"}}";
Map<String, String> headers = Map.of("wave-signature", "t=1234,v1=irrelevant");
PaymentEvent event = provider.processWebhook(json, headers);
assertThat(event.status()).isEqualTo(PaymentStatus.SUCCESS);
}
@Test
void processWebhook_whenSignatureInvalid_throwsPaymentException() throws Exception {
Field secretField = WavePaymentProvider.class.getDeclaredField("webhookSecret");
secretField.setAccessible(true);
secretField.set(provider, "secret123");
String json = "{\"type\":\"checkout.session.completed\",\"data\":{\"id\":\"W-EXT-006\",\"client_reference\":\"REF-006\",\"amount\":\"500\"}}";
Map<String, String> headers = Map.of("wave-signature", "t=1234,v1=invalidsignature");
assertThatThrownBy(() -> provider.processWebhook(json, headers))
.isInstanceOf(PaymentException.class)
.hasMessageContaining("invalide");
}
@Test
void processWebhook_whenSignatureValid_parsesEvent() throws Exception {
String secret = "test-secret";
Field secretField = WavePaymentProvider.class.getDeclaredField("webhookSecret");
secretField.setAccessible(true);
secretField.set(provider, secret);
String json = "{\"type\":\"checkout.session.completed\",\"data\":{\"id\":\"W-EXT-007\",\"client_reference\":\"REF-007\",\"amount\":\"750\"}}";
String timestamp = "1700000000";
String payload = timestamp + "." + json;
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(), "HmacSHA256"));
String computed = HexFormat.of().formatHex(mac.doFinal(payload.getBytes()));
Map<String, String> headers = Map.of("wave-signature", "t=" + timestamp + ",v1=" + computed);
PaymentEvent event = provider.processWebhook(json, headers);
assertThat(event.status()).isEqualTo(PaymentStatus.SUCCESS);
assertThat(event.externalId()).isEqualTo("W-EXT-007");
}
@Test
void processWebhook_whenSignatureHeaderUsesCapitalCase_isFound() throws Exception {
String secret = "cap-secret";
Field secretField = WavePaymentProvider.class.getDeclaredField("webhookSecret");
secretField.setAccessible(true);
secretField.set(provider, secret);
String json = "{\"type\":\"checkout.session.failed\",\"data\":{\"id\":\"W-EXT-008\",\"client_reference\":\"REF-008\",\"amount\":\"300\"}}";
String timestamp = "1700000001";
String payload = timestamp + "." + json;
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(), "HmacSHA256"));
String computed = HexFormat.of().formatHex(mac.doFinal(payload.getBytes()));
// Use capital "Wave-Signature" header
Map<String, String> headers = Map.of("Wave-Signature", "t=" + timestamp + ",v1=" + computed);
PaymentEvent event = provider.processWebhook(json, headers);
assertThat(event.status()).isEqualTo(PaymentStatus.FAILED);
}
@Test
void processWebhook_whenSecretSetAndSignatureHeaderMissing_throwsPaymentException() throws Exception {
Field secretField = WavePaymentProvider.class.getDeclaredField("webhookSecret");
secretField.setAccessible(true);
secretField.set(provider, "present-secret");
String json = "{\"type\":\"checkout.session.completed\",\"data\":{\"id\":\"W-EXT-009\",\"client_reference\":\"REF-009\",\"amount\":\"200\"}}";
assertThatThrownBy(() -> provider.processWebhook(json, Map.of()))
.isInstanceOf(PaymentException.class)
.hasMessageContaining("absente");
}
}

View File

@@ -0,0 +1,66 @@
package dev.lions.unionflow.server.repository;
import dev.lions.unionflow.server.entity.BackupConfig;
import io.quarkus.hibernate.orm.panache.PanacheQuery;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.*;
@DisplayName("BackupConfigRepository — tests unitaires")
class BackupConfigRepositoryTest {
/**
* Sous-classe concrète permettant d'instancier et d'espionner BackupConfigRepository
* sans CDI ni Panache réel.
*/
static class TestableBackupConfigRepository extends BackupConfigRepository {
// hérite de BackupConfigRepository sans contexte CDI
}
private BackupConfigRepository repo;
@BeforeEach
void setUp() {
repo = spy(new TestableBackupConfigRepository());
}
@Test
@DisplayName("classExists : le repository peut être instancié par réflexion")
void classExists() {
assertThat(new TestableBackupConfigRepository()).isNotNull();
}
@Test
@DisplayName("getConfig : retourne Optional.empty() si aucun résultat")
@SuppressWarnings("unchecked")
void getConfig_noResult_returnsEmpty() {
PanacheQuery<BackupConfig> query = mock(PanacheQuery.class);
when(query.firstResultOptional()).thenReturn(Optional.empty());
doReturn(query).when(repo).find(anyString());
Optional<BackupConfig> result = repo.getConfig();
assertThat(result).isEmpty();
}
@Test
@DisplayName("getConfig : retourne Optional.of(config) si une config existe")
@SuppressWarnings("unchecked")
void getConfig_withResult_returnsConfig() {
BackupConfig config = new BackupConfig();
PanacheQuery<BackupConfig> query = mock(PanacheQuery.class);
when(query.firstResultOptional()).thenReturn(Optional.of(config));
doReturn(query).when(repo).find(anyString());
Optional<BackupConfig> result = repo.getConfig();
assertThat(result).isPresent();
assertThat(result.get()).isSameAs(config);
}
}

View File

@@ -0,0 +1,102 @@
package dev.lions.unionflow.server.repository;
import dev.lions.unionflow.server.entity.BackupRecord;
import jakarta.persistence.EntityManager;
import jakarta.persistence.Query;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;
@DisplayName("BackupRecordRepository — tests unitaires")
class BackupRecordRepositoryTest {
/**
* Sous-classe concrète pour instancier BackupRecordRepository sans CDI.
*/
static class TestableBackupRecordRepository extends BackupRecordRepository {
TestableBackupRecordRepository(EntityManager em) {
super();
this.entityManager = em;
}
}
private EntityManager em;
private TestableBackupRecordRepository repo;
@BeforeEach
void setUp() {
em = mock(EntityManager.class);
repo = spy(new TestableBackupRecordRepository(em));
}
@Test
@DisplayName("constructeur : initialise entityClass à BackupRecord")
void constructor_setsEntityClass() {
assertThat(repo.entityClass).isEqualTo(BackupRecord.class);
}
@Test
@DisplayName("findAllOrderedByDate : délègue à findAll(Sort)")
void findAllOrderedByDate_delegatesToFindAll() {
io.quarkus.hibernate.orm.panache.PanacheQuery<BackupRecord> query =
mock(io.quarkus.hibernate.orm.panache.PanacheQuery.class);
when(query.list()).thenReturn(List.of());
doReturn(query).when(repo).findAll(any(io.quarkus.panache.common.Sort.class));
List<BackupRecord> result = repo.findAllOrderedByDate();
assertThat(result).isNotNull().isEmpty();
verify(repo).findAll(any(io.quarkus.panache.common.Sort.class));
}
@Test
@DisplayName("findAllOrderedByDate : retourne la liste des sauvegardes")
void findAllOrderedByDate_returnsNonEmptyList() {
BackupRecord record = new BackupRecord();
io.quarkus.hibernate.orm.panache.PanacheQuery<BackupRecord> query =
mock(io.quarkus.hibernate.orm.panache.PanacheQuery.class);
when(query.list()).thenReturn(List.of(record));
doReturn(query).when(repo).findAll(any(io.quarkus.panache.common.Sort.class));
List<BackupRecord> result = repo.findAllOrderedByDate();
assertThat(result).hasSize(1).contains(record);
}
@Test
@DisplayName("updateStatus : exécute la requête update Panache")
void updateStatus_executesUpdate() {
// updateStatus calls Panache update() which goes through the entity manager
// We mock the update call to verify it's invoked
doNothing().when(repo).persist(any(BackupRecord.class));
// Use a spy to verify the update call signature
UUID id = UUID.randomUUID();
LocalDateTime now = LocalDateTime.now();
// updateStatus calls PanacheRepositoryBase#update(String, Object...)
// We verify it doesn't throw and the call is attempted
// The full execution requires a real Panache context; here we just verify
// the method exists and can be called without NPE up to the Panache call
assertThat(repo).isNotNull();
// Verify signature is correct (no compilation errors means method exists)
// The actual Panache update() will fail without context, so we test defensively
try {
repo.updateStatus(id, "COMPLETED", 1024L, now, null);
} catch (Exception e) {
// Expected: no real EntityManager / Panache context
assertThat(e).isNotNull();
}
}
}

View File

@@ -0,0 +1,95 @@
package dev.lions.unionflow.server.repository;
import dev.lions.unionflow.server.entity.BaremeCotisationRole;
import io.quarkus.hibernate.orm.panache.PanacheQuery;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.*;
@DisplayName("BaremeCotisationRoleRepository — tests unitaires")
class BaremeCotisationRoleRepositoryTest {
static class TestableBaremeCotisationRoleRepository extends BaremeCotisationRoleRepository {
// instanciation sans CDI
}
private BaremeCotisationRoleRepository repo;
@BeforeEach
void setUp() {
repo = spy(new TestableBaremeCotisationRoleRepository());
}
@Test
@DisplayName("classExists : instanciation possible sans CDI")
void classExists() {
assertThat(new TestableBaremeCotisationRoleRepository()).isNotNull();
}
@Test
@DisplayName("findByOrganisationIdAndRoleOrg : retourne Optional.empty() si aucun résultat")
@SuppressWarnings("unchecked")
void findByOrganisationIdAndRoleOrg_noResult_returnsEmpty() {
PanacheQuery<BaremeCotisationRole> query = mock(PanacheQuery.class);
when(query.firstResultOptional()).thenReturn(Optional.empty());
doReturn(query).when(repo).find(anyString(), any(UUID.class), anyString());
Optional<BaremeCotisationRole> result =
repo.findByOrganisationIdAndRoleOrg(UUID.randomUUID(), "TRESORIER");
assertThat(result).isEmpty();
}
@Test
@DisplayName("findByOrganisationIdAndRoleOrg : retourne le barème trouvé")
@SuppressWarnings("unchecked")
void findByOrganisationIdAndRoleOrg_withResult_returnsBareme() {
BaremeCotisationRole bareme = new BaremeCotisationRole();
PanacheQuery<BaremeCotisationRole> query = mock(PanacheQuery.class);
when(query.firstResultOptional()).thenReturn(Optional.of(bareme));
doReturn(query).when(repo).find(anyString(), any(UUID.class), anyString());
Optional<BaremeCotisationRole> result =
repo.findByOrganisationIdAndRoleOrg(UUID.randomUUID(), "PRESIDENT");
assertThat(result).isPresent();
assertThat(result.get()).isSameAs(bareme);
}
@Test
@DisplayName("findByOrganisationId : retourne une liste vide si aucun barème")
@SuppressWarnings("unchecked")
void findByOrganisationId_noResult_returnsEmptyList() {
PanacheQuery<BaremeCotisationRole> query = mock(PanacheQuery.class);
when(query.list()).thenReturn(List.of());
doReturn(query).when(repo).find(anyString(), any(UUID.class));
List<BaremeCotisationRole> result = repo.findByOrganisationId(UUID.randomUUID());
assertThat(result).isEmpty();
}
@Test
@DisplayName("findByOrganisationId : retourne la liste des barèmes de l'organisation")
@SuppressWarnings("unchecked")
void findByOrganisationId_withResult_returnsList() {
BaremeCotisationRole b1 = new BaremeCotisationRole();
BaremeCotisationRole b2 = new BaremeCotisationRole();
PanacheQuery<BaremeCotisationRole> query = mock(PanacheQuery.class);
when(query.list()).thenReturn(List.of(b1, b2));
doReturn(query).when(repo).find(anyString(), any(UUID.class));
List<BaremeCotisationRole> result = repo.findByOrganisationId(UUID.randomUUID());
assertThat(result).hasSize(2).containsExactly(b1, b2);
}
}

View File

@@ -0,0 +1,173 @@
package dev.lions.unionflow.server.repository;
import dev.lions.unionflow.server.api.enums.abonnement.PlageMembres;
import dev.lions.unionflow.server.api.enums.abonnement.TypeFormule;
import dev.lions.unionflow.server.entity.FormuleAbonnement;
import jakarta.persistence.EntityManager;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.*;
@DisplayName("FormuleAbonnementRepository — tests unitaires")
class FormuleAbonnementRepositoryTest {
static class TestableFormuleAbonnementRepository extends FormuleAbonnementRepository {
TestableFormuleAbonnementRepository(EntityManager em) {
super();
this.entityManager = em;
}
}
private EntityManager em;
private TestableFormuleAbonnementRepository repo;
@BeforeEach
void setUp() {
em = mock(EntityManager.class);
repo = spy(new TestableFormuleAbonnementRepository(em));
}
@Test
@DisplayName("constructeur : initialise entityClass à FormuleAbonnement")
void constructor_setsEntityClass() {
assertThat(repo.entityClass).isEqualTo(FormuleAbonnement.class);
}
// ─── findByCodeAndPlage ───────────────────────────────────────────────────
@Test
@DisplayName("findByCodeAndPlage : retourne Optional.empty() si aucune formule")
@SuppressWarnings("unchecked")
void findByCodeAndPlage_noResult_returnsEmpty() {
io.quarkus.hibernate.orm.panache.PanacheQuery<FormuleAbonnement> query =
mock(io.quarkus.hibernate.orm.panache.PanacheQuery.class);
when(query.firstResultOptional()).thenReturn(Optional.empty());
doReturn(query).when(repo).find(anyString(), any(TypeFormule.class), any(PlageMembres.class));
Optional<FormuleAbonnement> result =
repo.findByCodeAndPlage(TypeFormule.BASIC, PlageMembres.PETITE);
assertThat(result).isEmpty();
}
@Test
@DisplayName("findByCodeAndPlage : retourne la formule correspondante")
@SuppressWarnings("unchecked")
void findByCodeAndPlage_withResult_returnsFormule() {
FormuleAbonnement formule = new FormuleAbonnement();
io.quarkus.hibernate.orm.panache.PanacheQuery<FormuleAbonnement> query =
mock(io.quarkus.hibernate.orm.panache.PanacheQuery.class);
when(query.firstResultOptional()).thenReturn(Optional.of(formule));
doReturn(query).when(repo).find(anyString(), any(TypeFormule.class), any(PlageMembres.class));
Optional<FormuleAbonnement> result =
repo.findByCodeAndPlage(TypeFormule.PREMIUM, PlageMembres.GRANDE);
assertThat(result).isPresent();
assertThat(result.get()).isSameAs(formule);
}
// ─── findAllActifOrderByOrdre ─────────────────────────────────────────────
@Test
@DisplayName("findAllActifOrderByOrdre : retourne liste vide si aucune formule active")
@SuppressWarnings("unchecked")
void findAllActifOrderByOrdre_noResult_returnsEmpty() {
io.quarkus.hibernate.orm.panache.PanacheQuery<FormuleAbonnement> query =
mock(io.quarkus.hibernate.orm.panache.PanacheQuery.class);
when(query.list()).thenReturn(List.of());
doReturn(query).when(repo).find(anyString());
List<FormuleAbonnement> result = repo.findAllActifOrderByOrdre();
assertThat(result).isEmpty();
}
@Test
@DisplayName("findAllActifOrderByOrdre : retourne la liste des formules actives")
@SuppressWarnings("unchecked")
void findAllActifOrderByOrdre_withResult_returnsList() {
FormuleAbonnement f1 = new FormuleAbonnement();
FormuleAbonnement f2 = new FormuleAbonnement();
io.quarkus.hibernate.orm.panache.PanacheQuery<FormuleAbonnement> query =
mock(io.quarkus.hibernate.orm.panache.PanacheQuery.class);
when(query.list()).thenReturn(List.of(f1, f2));
doReturn(query).when(repo).find(anyString());
List<FormuleAbonnement> result = repo.findAllActifOrderByOrdre();
assertThat(result).hasSize(2).containsExactly(f1, f2);
}
// ─── findByPlage ──────────────────────────────────────────────────────────
@Test
@DisplayName("findByPlage : retourne liste vide si aucune formule pour cette plage")
@SuppressWarnings("unchecked")
void findByPlage_noResult_returnsEmpty() {
io.quarkus.hibernate.orm.panache.PanacheQuery<FormuleAbonnement> query =
mock(io.quarkus.hibernate.orm.panache.PanacheQuery.class);
when(query.list()).thenReturn(List.of());
doReturn(query).when(repo).find(anyString(), any(PlageMembres.class));
List<FormuleAbonnement> result = repo.findByPlage(PlageMembres.TRES_GRANDE);
assertThat(result).isEmpty();
}
@Test
@DisplayName("findByPlage : retourne les formules de la plage")
@SuppressWarnings("unchecked")
void findByPlage_withResult_returnsList() {
FormuleAbonnement formule = new FormuleAbonnement();
io.quarkus.hibernate.orm.panache.PanacheQuery<FormuleAbonnement> query =
mock(io.quarkus.hibernate.orm.panache.PanacheQuery.class);
when(query.list()).thenReturn(List.of(formule));
doReturn(query).when(repo).find(anyString(), any(PlageMembres.class));
List<FormuleAbonnement> result = repo.findByPlage(PlageMembres.MOYENNE);
assertThat(result).hasSize(1);
}
// ─── findByCode ───────────────────────────────────────────────────────────
@Test
@DisplayName("findByCode : retourne liste vide si aucune formule pour ce niveau")
@SuppressWarnings("unchecked")
void findByCode_noResult_returnsEmpty() {
io.quarkus.hibernate.orm.panache.PanacheQuery<FormuleAbonnement> query =
mock(io.quarkus.hibernate.orm.panache.PanacheQuery.class);
when(query.list()).thenReturn(List.of());
doReturn(query).when(repo).find(anyString(), any(TypeFormule.class));
List<FormuleAbonnement> result = repo.findByCode(TypeFormule.STANDARD);
assertThat(result).isEmpty();
}
@Test
@DisplayName("findByCode : retourne les formules du niveau donné")
@SuppressWarnings("unchecked")
void findByCode_withResult_returnsList() {
FormuleAbonnement f1 = new FormuleAbonnement();
FormuleAbonnement f2 = new FormuleAbonnement();
FormuleAbonnement f3 = new FormuleAbonnement();
io.quarkus.hibernate.orm.panache.PanacheQuery<FormuleAbonnement> query =
mock(io.quarkus.hibernate.orm.panache.PanacheQuery.class);
when(query.list()).thenReturn(List.of(f1, f2, f3));
doReturn(query).when(repo).find(anyString(), any(TypeFormule.class));
List<FormuleAbonnement> result = repo.findByCode(TypeFormule.PREMIUM);
assertThat(result).hasSize(3);
}
}

View File

@@ -0,0 +1,224 @@
package dev.lions.unionflow.server.repository;
import dev.lions.unionflow.server.api.enums.membre.NiveauRisqueKyc;
import dev.lions.unionflow.server.api.enums.membre.StatutKyc;
import dev.lions.unionflow.server.entity.KycDossier;
import io.quarkus.hibernate.orm.panache.PanacheQuery;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
@DisplayName("KycDossierRepository — tests unitaires")
class KycDossierRepositoryTest {
static class TestableKycDossierRepository extends KycDossierRepository {
// instanciation sans CDI
}
private KycDossierRepository repo;
@BeforeEach
void setUp() {
repo = spy(new TestableKycDossierRepository());
}
@Test
@DisplayName("classExists : instanciation possible sans CDI")
void classExists() {
assertThat(new TestableKycDossierRepository()).isNotNull();
}
// ─── findDossierActifByMembre ─────────────────────────────────────────────
@Test
@DisplayName("findDossierActifByMembre : retourne Optional.empty() si aucun dossier")
@SuppressWarnings("unchecked")
void findDossierActifByMembre_noResult_returnsEmpty() {
PanacheQuery<KycDossier> query = mock(PanacheQuery.class);
when(query.firstResultOptional()).thenReturn(Optional.empty());
doReturn(query).when(repo).find(anyString(), any(UUID.class));
Optional<KycDossier> result = repo.findDossierActifByMembre(UUID.randomUUID());
assertThat(result).isEmpty();
}
@Test
@DisplayName("findDossierActifByMembre : retourne le dossier actif du membre")
@SuppressWarnings("unchecked")
void findDossierActifByMembre_withResult_returnsDossier() {
KycDossier dossier = new KycDossier();
PanacheQuery<KycDossier> query = mock(PanacheQuery.class);
when(query.firstResultOptional()).thenReturn(Optional.of(dossier));
doReturn(query).when(repo).find(anyString(), any(UUID.class));
Optional<KycDossier> result = repo.findDossierActifByMembre(UUID.randomUUID());
assertThat(result).isPresent().contains(dossier);
}
// ─── findByMembre ─────────────────────────────────────────────────────────
@Test
@DisplayName("findByMembre : retourne la liste des dossiers du membre")
@SuppressWarnings("unchecked")
void findByMembre_returnsList() {
KycDossier d1 = new KycDossier();
KycDossier d2 = new KycDossier();
PanacheQuery<KycDossier> query = mock(PanacheQuery.class);
when(query.list()).thenReturn(List.of(d1, d2));
doReturn(query).when(repo).find(anyString(), any(UUID.class));
List<KycDossier> result = repo.findByMembre(UUID.randomUUID());
assertThat(result).hasSize(2);
}
@Test
@DisplayName("findByMembre : retourne liste vide si aucun dossier")
@SuppressWarnings("unchecked")
void findByMembre_noResult_returnsEmpty() {
PanacheQuery<KycDossier> query = mock(PanacheQuery.class);
when(query.list()).thenReturn(List.of());
doReturn(query).when(repo).find(anyString(), any(UUID.class));
List<KycDossier> result = repo.findByMembre(UUID.randomUUID());
assertThat(result).isEmpty();
}
// ─── findByStatut ─────────────────────────────────────────────────────────
@Test
@DisplayName("findByStatut : retourne les dossiers du statut demandé")
@SuppressWarnings("unchecked")
void findByStatut_returnsList() {
KycDossier d = new KycDossier();
PanacheQuery<KycDossier> query = mock(PanacheQuery.class);
when(query.list()).thenReturn(List.of(d));
doReturn(query).when(repo).find(anyString(), any(StatutKyc.class));
List<KycDossier> result = repo.findByStatut(StatutKyc.EN_COURS);
assertThat(result).hasSize(1);
}
@Test
@DisplayName("findByStatut : tous les StatutKyc couverts")
void findByStatut_allStatutValues() {
assertThat(StatutKyc.NON_VERIFIE.getLibelle()).isEqualTo("Non vérifié");
assertThat(StatutKyc.EN_COURS.getLibelle()).isEqualTo("En cours");
assertThat(StatutKyc.VERIFIE.getLibelle()).isEqualTo("Vérifié");
assertThat(StatutKyc.REFUSE.getLibelle()).isEqualTo("Refusé");
}
// ─── findByNiveauRisque ───────────────────────────────────────────────────
@Test
@DisplayName("findByNiveauRisque : retourne les dossiers du niveau de risque")
@SuppressWarnings("unchecked")
void findByNiveauRisque_returnsList() {
PanacheQuery<KycDossier> query = mock(PanacheQuery.class);
when(query.list()).thenReturn(List.of());
doReturn(query).when(repo).find(anyString(), any(NiveauRisqueKyc.class));
List<KycDossier> result = repo.findByNiveauRisque(NiveauRisqueKyc.ELEVE);
assertThat(result).isNotNull();
}
@Test
@DisplayName("NiveauRisqueKyc : toutes les valeurs accessibles")
void niveauRisqueEnum_allValues() {
assertThat(NiveauRisqueKyc.FAIBLE.getLibelle()).isEqualTo("Risque faible");
assertThat(NiveauRisqueKyc.MOYEN.getLibelle()).isEqualTo("Risque moyen");
assertThat(NiveauRisqueKyc.ELEVE.getLibelle()).isEqualTo("Risque élevé");
assertThat(NiveauRisqueKyc.CRITIQUE.getLibelle()).isEqualTo("Risque critique");
assertThat(NiveauRisqueKyc.fromScore(20)).isEqualTo(NiveauRisqueKyc.FAIBLE);
assertThat(NiveauRisqueKyc.fromScore(55)).isEqualTo(NiveauRisqueKyc.MOYEN);
assertThat(NiveauRisqueKyc.fromScore(75)).isEqualTo(NiveauRisqueKyc.ELEVE);
assertThat(NiveauRisqueKyc.fromScore(95)).isEqualTo(NiveauRisqueKyc.CRITIQUE);
// score hors plage → CRITIQUE
assertThat(NiveauRisqueKyc.fromScore(999)).isEqualTo(NiveauRisqueKyc.CRITIQUE);
}
// ─── findPep ─────────────────────────────────────────────────────────────
@Test
@DisplayName("findPep : retourne les dossiers PEP actifs")
@SuppressWarnings("unchecked")
void findPep_returnsList() {
PanacheQuery<KycDossier> query = mock(PanacheQuery.class);
when(query.list()).thenReturn(List.of());
doReturn(query).when(repo).find(anyString());
List<KycDossier> result = repo.findPep();
assertThat(result).isNotNull();
}
// ─── findPiecesExpirantsAvant ─────────────────────────────────────────────
@Test
@DisplayName("findPiecesExpirantsAvant : retourne les dossiers avec pièce expirant avant la date")
@SuppressWarnings("unchecked")
void findPiecesExpirantsAvant_returnsList() {
PanacheQuery<KycDossier> query = mock(PanacheQuery.class);
when(query.list()).thenReturn(List.of());
doReturn(query).when(repo).find(anyString(), any(LocalDate.class));
List<KycDossier> result = repo.findPiecesExpirantsAvant(LocalDate.now().plusDays(30));
assertThat(result).isNotNull();
}
// ─── countByStatut ────────────────────────────────────────────────────────
@Test
@DisplayName("countByStatut : retourne le nombre de dossiers du statut")
void countByStatut_returnsCount() {
doReturn(3L).when(repo).count(anyString(), any(StatutKyc.class));
long result = repo.countByStatut(StatutKyc.VERIFIE);
assertThat(result).isEqualTo(3L);
}
// ─── countPepActifs ───────────────────────────────────────────────────────
@Test
@DisplayName("countPepActifs : retourne le nombre de PEP actifs")
void countPepActifs_returnsCount() {
doReturn(2L).when(repo).count(anyString());
long result = repo.countPepActifs();
assertThat(result).isEqualTo(2L);
}
// ─── findByAnnee ──────────────────────────────────────────────────────────
@Test
@DisplayName("findByAnnee : retourne les dossiers de l'année de référence")
@SuppressWarnings("unchecked")
void findByAnnee_returnsList() {
KycDossier d = new KycDossier();
PanacheQuery<KycDossier> query = mock(PanacheQuery.class);
when(query.list()).thenReturn(List.of(d));
doReturn(query).when(repo).find(anyString(), eq(2025));
List<KycDossier> result = repo.findByAnnee(2025);
assertThat(result).hasSize(1);
}
}

View File

@@ -0,0 +1,222 @@
package dev.lions.unionflow.server.repository;
import dev.lions.unionflow.server.entity.Paiement;
import io.quarkus.hibernate.orm.panache.PanacheQuery;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
@DisplayName("PaiementRepository — tests unitaires")
class PaiementRepositoryTest {
static class TestablePaiementRepository extends PaiementRepository {
// instanciation sans CDI
}
private PaiementRepository repo;
@BeforeEach
void setUp() {
repo = spy(new TestablePaiementRepository());
}
@Test
@DisplayName("classExists : instanciation possible sans CDI")
void classExists() {
assertThat(new TestablePaiementRepository()).isNotNull();
}
// ─── findPaiementById ─────────────────────────────────────────────────────
@Test
@DisplayName("findPaiementById : retourne Optional.empty() si aucun paiement")
@SuppressWarnings("unchecked")
void findPaiementById_noResult_returnsEmpty() {
PanacheQuery<Paiement> query = mock(PanacheQuery.class);
when(query.firstResultOptional()).thenReturn(Optional.empty());
doReturn(query).when(repo).find(anyString(), any(UUID.class));
Optional<Paiement> result = repo.findPaiementById(UUID.randomUUID());
assertThat(result).isEmpty();
}
@Test
@DisplayName("findPaiementById : retourne le paiement si trouvé")
@SuppressWarnings("unchecked")
void findPaiementById_withResult_returnsPaiement() {
Paiement paiement = new Paiement();
PanacheQuery<Paiement> query = mock(PanacheQuery.class);
when(query.firstResultOptional()).thenReturn(Optional.of(paiement));
doReturn(query).when(repo).find(anyString(), any(UUID.class));
Optional<Paiement> result = repo.findPaiementById(UUID.randomUUID());
assertThat(result).isPresent().contains(paiement);
}
// ─── findByNumeroReference ────────────────────────────────────────────────
@Test
@DisplayName("findByNumeroReference : retourne Optional.empty() si référence inconnue")
@SuppressWarnings("unchecked")
void findByNumeroReference_noResult_returnsEmpty() {
PanacheQuery<Paiement> query = mock(PanacheQuery.class);
when(query.firstResultOptional()).thenReturn(Optional.empty());
doReturn(query).when(repo).find(anyString(), anyString());
Optional<Paiement> result = repo.findByNumeroReference("PAY-UNKNOWN-999");
assertThat(result).isEmpty();
}
@Test
@DisplayName("findByNumeroReference : retourne le paiement correspondant")
@SuppressWarnings("unchecked")
void findByNumeroReference_withResult_returnsPaiement() {
Paiement paiement = new Paiement();
PanacheQuery<Paiement> query = mock(PanacheQuery.class);
when(query.firstResultOptional()).thenReturn(Optional.of(paiement));
doReturn(query).when(repo).find(anyString(), anyString());
Optional<Paiement> result = repo.findByNumeroReference("PAY-2026-001");
assertThat(result).isPresent().contains(paiement);
}
// ─── findByMembreId ───────────────────────────────────────────────────────
@Test
@DisplayName("findByMembreId : retourne liste vide si aucun paiement")
@SuppressWarnings("unchecked")
void findByMembreId_noResult_returnsEmpty() {
PanacheQuery<Paiement> query = mock(PanacheQuery.class);
when(query.list()).thenReturn(List.of());
doReturn(query).when(repo).find(anyString(), any(io.quarkus.panache.common.Sort.class), any(UUID.class));
List<Paiement> result = repo.findByMembreId(UUID.randomUUID());
assertThat(result).isEmpty();
}
@Test
@DisplayName("findByMembreId : retourne les paiements du membre")
@SuppressWarnings("unchecked")
void findByMembreId_withResult_returnsList() {
Paiement p1 = new Paiement();
Paiement p2 = new Paiement();
PanacheQuery<Paiement> query = mock(PanacheQuery.class);
when(query.list()).thenReturn(List.of(p1, p2));
doReturn(query).when(repo).find(anyString(), any(io.quarkus.panache.common.Sort.class), any(UUID.class));
List<Paiement> result = repo.findByMembreId(UUID.randomUUID());
assertThat(result).hasSize(2);
}
// ─── findByStatut ─────────────────────────────────────────────────────────
@Test
@DisplayName("findByStatut : retourne les paiements du statut donné")
@SuppressWarnings("unchecked")
void findByStatut_returnsList() {
PanacheQuery<Paiement> query = mock(PanacheQuery.class);
when(query.list()).thenReturn(List.of());
doReturn(query).when(repo).find(anyString(), any(io.quarkus.panache.common.Sort.class), anyString());
List<Paiement> result = repo.findByStatut("VALIDE");
assertThat(result).isNotNull();
}
// ─── findByMethode ────────────────────────────────────────────────────────
@Test
@DisplayName("findByMethode : retourne les paiements de la méthode donnée")
@SuppressWarnings("unchecked")
void findByMethode_returnsList() {
PanacheQuery<Paiement> query = mock(PanacheQuery.class);
when(query.list()).thenReturn(List.of());
doReturn(query).when(repo).find(anyString(), any(io.quarkus.panache.common.Sort.class), anyString());
List<Paiement> result = repo.findByMethode("WAVE");
assertThat(result).isNotNull();
}
// ─── findValidesParPeriode ────────────────────────────────────────────────
@Test
@DisplayName("findValidesParPeriode : retourne la liste des paiements validés dans la période")
@SuppressWarnings("unchecked")
void findValidesParPeriode_returnsList() {
PanacheQuery<Paiement> query = mock(PanacheQuery.class);
when(query.list()).thenReturn(List.of());
doReturn(query).when(repo).find(
anyString(),
any(io.quarkus.panache.common.Sort.class),
any(LocalDateTime.class),
any(LocalDateTime.class));
LocalDateTime debut = LocalDateTime.now().minusDays(30);
LocalDateTime fin = LocalDateTime.now();
List<Paiement> result = repo.findValidesParPeriode(debut, fin);
assertThat(result).isNotNull();
}
// ─── calculerMontantTotalValides ──────────────────────────────────────────
@Test
@DisplayName("calculerMontantTotalValides : retourne ZERO si aucun paiement validé")
@SuppressWarnings("unchecked")
void calculerMontantTotalValides_noPaiements_returnsZero() {
PanacheQuery<Paiement> query = mock(PanacheQuery.class);
when(query.list()).thenReturn(List.of());
doReturn(query).when(repo).find(
anyString(),
any(io.quarkus.panache.common.Sort.class),
any(LocalDateTime.class),
any(LocalDateTime.class));
LocalDateTime debut = LocalDateTime.now().minusDays(30);
LocalDateTime fin = LocalDateTime.now();
BigDecimal total = repo.calculerMontantTotalValides(debut, fin);
assertThat(total).isEqualByComparingTo(BigDecimal.ZERO);
}
@Test
@DisplayName("calculerMontantTotalValides : somme les montants des paiements validés")
@SuppressWarnings("unchecked")
void calculerMontantTotalValides_withPaiements_returnsSum() {
Paiement p1 = new Paiement();
p1.setMontant(new BigDecimal("10000"));
Paiement p2 = new Paiement();
p2.setMontant(new BigDecimal("5000"));
PanacheQuery<Paiement> query = mock(PanacheQuery.class);
when(query.list()).thenReturn(List.of(p1, p2));
doReturn(query).when(repo).find(
anyString(),
any(io.quarkus.panache.common.Sort.class),
any(LocalDateTime.class),
any(LocalDateTime.class));
LocalDateTime debut = LocalDateTime.now().minusDays(30);
LocalDateTime fin = LocalDateTime.now();
BigDecimal total = repo.calculerMontantTotalValides(debut, fin);
assertThat(total).isEqualByComparingTo("15000");
}
}

View File

@@ -0,0 +1,100 @@
package dev.lions.unionflow.server.repository;
import dev.lions.unionflow.server.entity.ParametresCotisationOrganisation;
import io.quarkus.hibernate.orm.panache.PanacheQuery;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
@DisplayName("ParametresCotisationOrganisationRepository — tests unitaires")
class ParametresCotisationOrganisationRepositoryTest {
static class TestableParametresCotisationOrganisationRepository
extends ParametresCotisationOrganisationRepository {
// instanciation sans CDI
}
private ParametresCotisationOrganisationRepository repo;
@BeforeEach
void setUp() {
repo = spy(new TestableParametresCotisationOrganisationRepository());
}
@Test
@DisplayName("classExists : instanciation possible sans CDI")
void classExists() {
assertThat(new TestableParametresCotisationOrganisationRepository()).isNotNull();
}
// ─── findByOrganisationId ─────────────────────────────────────────────────
@Test
@DisplayName("findByOrganisationId : retourne Optional.empty() si aucun paramètre")
@SuppressWarnings("unchecked")
void findByOrganisationId_noResult_returnsEmpty() {
PanacheQuery<ParametresCotisationOrganisation> query = mock(PanacheQuery.class);
when(query.firstResultOptional()).thenReturn(Optional.empty());
doReturn(query).when(repo).find(anyString(), any(UUID.class));
Optional<ParametresCotisationOrganisation> result =
repo.findByOrganisationId(UUID.randomUUID());
assertThat(result).isEmpty();
}
@Test
@DisplayName("findByOrganisationId : retourne les paramètres de l'organisation")
@SuppressWarnings("unchecked")
void findByOrganisationId_withResult_returnsParametres() {
ParametresCotisationOrganisation parametres = new ParametresCotisationOrganisation();
PanacheQuery<ParametresCotisationOrganisation> query = mock(PanacheQuery.class);
when(query.firstResultOptional()).thenReturn(Optional.of(parametres));
doReturn(query).when(repo).find(anyString(), any(UUID.class));
Optional<ParametresCotisationOrganisation> result =
repo.findByOrganisationId(UUID.randomUUID());
assertThat(result).isPresent().contains(parametres);
}
// ─── findAvecGenerationAutomatiqueActivee ─────────────────────────────────
@Test
@DisplayName("findAvecGenerationAutomatiqueActivee : retourne liste vide si aucune organisation")
@SuppressWarnings("unchecked")
void findAvecGenerationAutomatiqueActivee_noResult_returnsEmpty() {
PanacheQuery<ParametresCotisationOrganisation> query = mock(PanacheQuery.class);
when(query.list()).thenReturn(List.of());
doReturn(query).when(repo).find(anyString());
List<ParametresCotisationOrganisation> result =
repo.findAvecGenerationAutomatiqueActivee();
assertThat(result).isEmpty();
}
@Test
@DisplayName("findAvecGenerationAutomatiqueActivee : retourne les organisations avec génération auto activée")
@SuppressWarnings("unchecked")
void findAvecGenerationAutomatiqueActivee_withResult_returnsList() {
ParametresCotisationOrganisation p1 = new ParametresCotisationOrganisation();
ParametresCotisationOrganisation p2 = new ParametresCotisationOrganisation();
PanacheQuery<ParametresCotisationOrganisation> query = mock(PanacheQuery.class);
when(query.list()).thenReturn(List.of(p1, p2));
doReturn(query).when(repo).find(anyString());
List<ParametresCotisationOrganisation> result =
repo.findAvecGenerationAutomatiqueActivee();
assertThat(result).hasSize(2).containsExactly(p1, p2);
}
}

Some files were not shown because too many files have changed in this diff Show More