fix(disaster-recovery): restaurer 134 fichiers accidentellement supprimés par a72ab54
Some checks failed
CI/CD Pipeline / pipeline (push) Failing after 3m46s
Some checks failed
CI/CD Pipeline / pipeline (push) Failing after 3m46s
Le commita72ab54(chore docker Dockerfile racine) a involontairement balayé des fichiers du commit31330d9(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:
112
src/main/java/dev/lions/unionflow/server/entity/KycDossier.java
Normal file
112
src/main/java/dev/lions/unionflow/server/entity/KycDossier.java
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("\"", """)
|
||||
.replace("'", "'");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
) {}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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 $$;
|
||||
@@ -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 $$;
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
|
||||
@@ -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.';
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
42
src/main/resources/templates/email/bienvenue.html
Normal file
42
src/main/resources/templates/email/bienvenue.html
Normal 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>
|
||||
@@ -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>
|
||||
45
src/main/resources/templates/email/rappelCotisation.html
Normal file
45
src/main/resources/templates/email/rappelCotisation.html
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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 ");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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-");
|
||||
}
|
||||
}
|
||||
@@ -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-");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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 & Co <Tag>");
|
||||
assertThat(xml).doesNotContain("<Tag>");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user