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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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