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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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