fix(disaster-recovery): restaurer 134 fichiers accidentellement supprimés par a72ab54
Some checks failed
CI/CD Pipeline / pipeline (push) Failing after 3m46s
Some checks failed
CI/CD Pipeline / pipeline (push) Failing after 3m46s
Le commita72ab54(chore docker Dockerfile racine) a involontairement balayé des fichiers du commit31330d9(PI-SPI, KYC, RLS, mutuelle parts, comptabilité PDF) lors d'un git add -A trop large. Restauration de l'intégralité des fichiers depuis a72ab54^ : - 11 migrations Flyway V32-V42 (parts sociales, SYSCOHADA, Keycloak Org Id, KYC, RLS, Provider défaut, FCM, App DB Roles) - Package payment/pispi/ complet (PispiAuth, PispiClient, PispiIso20022Mapper, PispiSignatureVerifier, PispiWebhookResource, dto/Pacs008Request, dto/Pacs002Response, PispiPaymentProvider) - Package payment/{wave,orangemoney,mtnmomo}/* (PaymentProvider impls) - Package payment/orchestration/ (PaymentOrchestrator, PaymentProviderRegistry) - Entités KycDossier, mutuelle/parts/* (ComptePartsSociales, TransactionPartsSociales) - Mappers, repositories, resources associés - Services KycAmlService, ComptabilitePdfService, ReleveComptePdfService, InteretsEpargneService - AdminKeycloakOrganisationResource, KycResource, PaiementUnifieResource - Tests unitaires PI-SPI, KYC, mutuelle parts
This commit is contained in:
112
src/main/java/dev/lions/unionflow/server/entity/KycDossier.java
Normal file
112
src/main/java/dev/lions/unionflow/server/entity/KycDossier.java
Normal file
@@ -0,0 +1,112 @@
|
||||
package dev.lions.unionflow.server.entity;
|
||||
|
||||
import dev.lions.unionflow.server.api.enums.membre.NiveauRisqueKyc;
|
||||
import dev.lions.unionflow.server.api.enums.membre.StatutKyc;
|
||||
import dev.lions.unionflow.server.api.enums.membre.TypePieceIdentite;
|
||||
import jakarta.persistence.*;
|
||||
import jakarta.validation.constraints.*;
|
||||
import lombok.*;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Dossier KYC/AML d'un membre — conformité GIABA/BCEAO LCB-FT.
|
||||
*
|
||||
* <p>Rétention 10 ans requise par le GIABA. La colonne {@code anneeReference}
|
||||
* sert à l'archivage logique par année (partitionnement futur PostgreSQL).
|
||||
*
|
||||
* <p>Un seul dossier actif ({@code actif=true}) par membre à la fois.
|
||||
* Les dossiers expirés ou archivés ont {@code actif=false}.
|
||||
*/
|
||||
@Entity
|
||||
@Table(
|
||||
name = "kyc_dossier",
|
||||
indexes = {
|
||||
@Index(name = "idx_kyc_membre_id", columnList = "membre_id"),
|
||||
@Index(name = "idx_kyc_statut", columnList = "statut"),
|
||||
@Index(name = "idx_kyc_niveau_risque", columnList = "niveau_risque"),
|
||||
@Index(name = "idx_kyc_annee", columnList = "annee_reference")
|
||||
}
|
||||
)
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class KycDossier extends BaseEntity {
|
||||
|
||||
@NotNull
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "membre_id", nullable = false)
|
||||
private Membre membre;
|
||||
|
||||
@NotNull
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "type_piece", nullable = false, length = 30)
|
||||
private TypePieceIdentite typePiece;
|
||||
|
||||
@NotBlank
|
||||
@Size(max = 50)
|
||||
@Column(name = "numero_piece", nullable = false, length = 50)
|
||||
private String numeroPiece;
|
||||
|
||||
@Column(name = "date_expiration_piece")
|
||||
private LocalDate dateExpirationPiece;
|
||||
|
||||
@Size(max = 500)
|
||||
@Column(name = "piece_identite_recto_file_id", length = 500)
|
||||
private String pieceIdentiteRectoFileId;
|
||||
|
||||
@Size(max = 500)
|
||||
@Column(name = "piece_identite_verso_file_id", length = 500)
|
||||
private String pieceIdentiteVersoFileId;
|
||||
|
||||
@Size(max = 500)
|
||||
@Column(name = "justif_domicile_file_id", length = 500)
|
||||
private String justifDomicileFileId;
|
||||
|
||||
@NotNull
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "statut", nullable = false, length = 20)
|
||||
@Builder.Default
|
||||
private StatutKyc statut = StatutKyc.NON_VERIFIE;
|
||||
|
||||
@NotNull
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "niveau_risque", nullable = false, length = 20)
|
||||
@Builder.Default
|
||||
private NiveauRisqueKyc niveauRisque = NiveauRisqueKyc.FAIBLE;
|
||||
|
||||
@Min(0) @Max(100)
|
||||
@Column(name = "score_risque", nullable = false)
|
||||
@Builder.Default
|
||||
private int scoreRisque = 0;
|
||||
|
||||
@Builder.Default
|
||||
@Column(name = "est_pep", nullable = false)
|
||||
private boolean estPep = false;
|
||||
|
||||
@Size(max = 5)
|
||||
@Column(name = "nationalite", length = 5)
|
||||
private String nationalite;
|
||||
|
||||
@Column(name = "date_verification")
|
||||
private LocalDateTime dateVerification;
|
||||
|
||||
@Column(name = "validateur_id")
|
||||
private UUID validateurId;
|
||||
|
||||
@Size(max = 1000)
|
||||
@Column(name = "notes_validateur", length = 1000)
|
||||
private String notesValidateur;
|
||||
|
||||
@Column(name = "annee_reference", nullable = false)
|
||||
@Builder.Default
|
||||
private int anneeReference = java.time.LocalDate.now().getYear();
|
||||
|
||||
public boolean isPieceExpiree() {
|
||||
return dateExpirationPiece != null && dateExpirationPiece.isBefore(LocalDate.now());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package dev.lions.unionflow.server.entity.mutuelle;
|
||||
|
||||
import dev.lions.unionflow.server.entity.BaseEntity;
|
||||
import dev.lions.unionflow.server.entity.Organisation;
|
||||
import jakarta.persistence.*;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.*;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
|
||||
@Entity
|
||||
@Table(name = "parametres_financiers_mutuelle", indexes = {
|
||||
@Index(name = "idx_pfm_org", columnList = "organisation_id", unique = true)
|
||||
})
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class ParametresFinanciersMutuelle extends BaseEntity {
|
||||
|
||||
@OneToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "organisation_id", nullable = false, unique = true)
|
||||
private Organisation organisation;
|
||||
|
||||
/** Valeur nominale par défaut d'une part sociale */
|
||||
@NotNull
|
||||
@Column(name = "valeur_nominale_par_defaut", nullable = false, precision = 19, scale = 4)
|
||||
@Builder.Default
|
||||
private BigDecimal valeurNominaleParDefaut = new BigDecimal("5000");
|
||||
|
||||
/** Taux d'intérêt annuel sur l'épargne, ex: 0.03 = 3% */
|
||||
@NotNull
|
||||
@Column(name = "taux_interet_annuel_epargne", nullable = false, precision = 6, scale = 4)
|
||||
@Builder.Default
|
||||
private BigDecimal tauxInteretAnnuelEpargne = new BigDecimal("0.03");
|
||||
|
||||
/** Taux de dividende annuel sur les parts sociales, ex: 0.05 = 5% */
|
||||
@NotNull
|
||||
@Column(name = "taux_dividende_parts_annuel", nullable = false, precision = 6, scale = 4)
|
||||
@Builder.Default
|
||||
private BigDecimal tauxDividendePartsAnnuel = new BigDecimal("0.05");
|
||||
|
||||
/** MENSUEL | TRIMESTRIEL | ANNUEL */
|
||||
@NotNull
|
||||
@Column(name = "periodicite_calcul", nullable = false, length = 20)
|
||||
@Builder.Default
|
||||
private String periodiciteCalcul = "MENSUEL";
|
||||
|
||||
/** Solde minimum en dessous duquel les intérêts ne s'appliquent pas */
|
||||
@Column(name = "seuil_min_epargne_interets", precision = 19, scale = 4)
|
||||
@Builder.Default
|
||||
private BigDecimal seuilMinEpargneInterets = BigDecimal.ZERO;
|
||||
|
||||
/** Date du prochain calcul planifié */
|
||||
@Column(name = "prochaine_calcul_interets")
|
||||
private LocalDate prochaineCalculInterets;
|
||||
|
||||
/** Date du dernier calcul effectué */
|
||||
@Column(name = "dernier_calcul_interets")
|
||||
private LocalDate dernierCalculInterets;
|
||||
|
||||
/** Nombre de comptes traités lors du dernier calcul */
|
||||
@Column(name = "dernier_nb_comptes_traites")
|
||||
@Builder.Default
|
||||
private Integer dernierNbComptesTraites = 0;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package dev.lions.unionflow.server.entity.mutuelle.parts;
|
||||
|
||||
import dev.lions.unionflow.server.api.enums.mutuelle.parts.StatutComptePartsSociales;
|
||||
import dev.lions.unionflow.server.entity.BaseEntity;
|
||||
import dev.lions.unionflow.server.entity.Membre;
|
||||
import dev.lions.unionflow.server.entity.Organisation;
|
||||
import jakarta.persistence.*;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.*;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
|
||||
@Entity
|
||||
@Table(name = "comptes_parts_sociales", indexes = {
|
||||
@Index(name = "idx_cps_numero", columnList = "numero_compte", unique = true),
|
||||
@Index(name = "idx_cps_membre", columnList = "membre_id"),
|
||||
@Index(name = "idx_cps_org", columnList = "organisation_id")
|
||||
})
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class ComptePartsSociales extends BaseEntity {
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "membre_id", nullable = false)
|
||||
private Membre membre;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "organisation_id", nullable = false)
|
||||
private Organisation organisation;
|
||||
|
||||
@NotBlank
|
||||
@Column(name = "numero_compte", unique = true, nullable = false, length = 50)
|
||||
private String numeroCompte;
|
||||
|
||||
@NotNull
|
||||
@Min(0)
|
||||
@Column(name = "nombre_parts", nullable = false)
|
||||
@Builder.Default
|
||||
private Integer nombreParts = 0;
|
||||
|
||||
@NotNull
|
||||
@Column(name = "valeur_nominale", nullable = false, precision = 19, scale = 4)
|
||||
private BigDecimal valeurNominale;
|
||||
|
||||
/** nombreParts × valeurNominale — mis à jour à chaque transaction */
|
||||
@NotNull
|
||||
@Column(name = "montant_total", nullable = false, precision = 19, scale = 4)
|
||||
@Builder.Default
|
||||
private BigDecimal montantTotal = BigDecimal.ZERO;
|
||||
|
||||
@NotNull
|
||||
@Column(name = "total_dividendes_recus", nullable = false, precision = 19, scale = 4)
|
||||
@Builder.Default
|
||||
private BigDecimal totalDividendesRecus = BigDecimal.ZERO;
|
||||
|
||||
@NotNull
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "statut", nullable = false, length = 30)
|
||||
@Builder.Default
|
||||
private StatutComptePartsSociales statut = StatutComptePartsSociales.ACTIF;
|
||||
|
||||
@NotNull
|
||||
@Column(name = "date_ouverture", nullable = false)
|
||||
@Builder.Default
|
||||
private LocalDate dateOuverture = LocalDate.now();
|
||||
|
||||
@Column(name = "date_derniere_operation")
|
||||
private LocalDate dateDerniereOperation;
|
||||
|
||||
@Column(name = "notes", length = 500)
|
||||
private String notes;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package dev.lions.unionflow.server.entity.mutuelle.parts;
|
||||
|
||||
import dev.lions.unionflow.server.api.enums.mutuelle.parts.TypeTransactionPartsSociales;
|
||||
import dev.lions.unionflow.server.entity.BaseEntity;
|
||||
import jakarta.persistence.*;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.*;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Entity
|
||||
@Table(name = "transactions_parts_sociales", indexes = {
|
||||
@Index(name = "idx_tps_compte", columnList = "compte_id"),
|
||||
@Index(name = "idx_tps_date", columnList = "date_transaction")
|
||||
})
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class TransactionPartsSociales extends BaseEntity {
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "compte_id", nullable = false)
|
||||
private ComptePartsSociales compte;
|
||||
|
||||
@NotNull
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "type_transaction", nullable = false, length = 50)
|
||||
private TypeTransactionPartsSociales typeTransaction;
|
||||
|
||||
@NotNull
|
||||
@Min(1)
|
||||
@Column(name = "nombre_parts", nullable = false)
|
||||
private Integer nombreParts;
|
||||
|
||||
@NotNull
|
||||
@Column(name = "montant", nullable = false, precision = 19, scale = 4)
|
||||
private BigDecimal montant;
|
||||
|
||||
@Column(name = "solde_parts_avant", nullable = false)
|
||||
@Builder.Default
|
||||
private Integer soldePartsAvant = 0;
|
||||
|
||||
@Column(name = "solde_parts_apres", nullable = false)
|
||||
@Builder.Default
|
||||
private Integer soldePartsApres = 0;
|
||||
|
||||
@Column(name = "motif", length = 500)
|
||||
private String motif;
|
||||
|
||||
@Column(name = "reference_externe", length = 100)
|
||||
private String referenceExterne;
|
||||
|
||||
@NotNull
|
||||
@Column(name = "date_transaction", nullable = false)
|
||||
@Builder.Default
|
||||
private LocalDateTime dateTransaction = LocalDateTime.now();
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package dev.lions.unionflow.server.mapper.mutuelle.parts;
|
||||
|
||||
import dev.lions.unionflow.server.api.dto.mutuelle.parts.ComptePartsSocialesResponse;
|
||||
import dev.lions.unionflow.server.entity.mutuelle.parts.ComptePartsSociales;
|
||||
import org.mapstruct.Mapper;
|
||||
import org.mapstruct.Mapping;
|
||||
|
||||
@Mapper(componentModel = "cdi", builder = @org.mapstruct.Builder(disableBuilder = true))
|
||||
public interface ComptePartsSocialesMapper {
|
||||
|
||||
@Mapping(target = "membreId", expression = "java(entity.getMembre() != null ? entity.getMembre().getId().toString() : null)")
|
||||
@Mapping(target = "membreNomComplet", expression = "java(entity.getMembre() != null ? entity.getMembre().getNom() + ' ' + entity.getMembre().getPrenom() : null)")
|
||||
@Mapping(target = "organisationId", expression = "java(entity.getOrganisation() != null ? entity.getOrganisation().getId().toString() : null)")
|
||||
ComptePartsSocialesResponse toDto(ComptePartsSociales entity);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package dev.lions.unionflow.server.mapper.mutuelle.parts;
|
||||
|
||||
import dev.lions.unionflow.server.api.dto.mutuelle.parts.TransactionPartsSocialesResponse;
|
||||
import dev.lions.unionflow.server.entity.mutuelle.parts.TransactionPartsSociales;
|
||||
import org.mapstruct.Mapper;
|
||||
import org.mapstruct.Mapping;
|
||||
|
||||
@Mapper(componentModel = "cdi", builder = @org.mapstruct.Builder(disableBuilder = true))
|
||||
public interface TransactionPartsSocialesMapper {
|
||||
|
||||
@Mapping(target = "compteId", expression = "java(entity.getCompte() != null ? entity.getCompte().getId().toString() : null)")
|
||||
@Mapping(target = "numeroCompte", expression = "java(entity.getCompte() != null ? entity.getCompte().getNumeroCompte() : null)")
|
||||
@Mapping(target = "typeTransactionLibelle", expression = "java(entity.getTypeTransaction() != null ? entity.getTypeTransaction().getLibelle() : null)")
|
||||
TransactionPartsSocialesResponse toDto(TransactionPartsSociales entity);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package dev.lions.unionflow.server.payment.mtnmomo;
|
||||
|
||||
import dev.lions.unionflow.server.api.payment.*;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.eclipse.microprofile.config.inject.ConfigProperty;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Provider MTN MoMo (stub — à implémenter avec l'API MTN Mobile Money).
|
||||
*
|
||||
* <p>Sandbox : https://sandbox.momodeveloper.mtn.com
|
||||
* Requis : subscription-key, api-user, api-key (via provisioning sandbox).
|
||||
*/
|
||||
@Slf4j
|
||||
@ApplicationScoped
|
||||
public class MtnMomoPaymentProvider implements PaymentProvider {
|
||||
|
||||
public static final String CODE = "MTN_MOMO";
|
||||
|
||||
@ConfigProperty(name = "mtnmomo.collection.subscription-key")
|
||||
Optional<String> subscriptionKeyOpt;
|
||||
|
||||
@ConfigProperty(name = "mtnmomo.api.base-url", defaultValue = "https://sandbox.momodeveloper.mtn.com")
|
||||
String baseUrl;
|
||||
|
||||
String subscriptionKey;
|
||||
|
||||
@jakarta.annotation.PostConstruct
|
||||
void init() {
|
||||
subscriptionKey = subscriptionKeyOpt.orElse("");
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getProviderCode() {
|
||||
return CODE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public CheckoutSession initiateCheckout(CheckoutRequest request) throws PaymentException {
|
||||
if (subscriptionKey == null || subscriptionKey.isBlank()) {
|
||||
log.warn("MTN MoMo non configuré — mode mock actif pour ref={}", request.reference());
|
||||
String mockId = "MTN-MOCK-" + UUID.randomUUID().toString().substring(0, 8).toUpperCase();
|
||||
return new CheckoutSession(mockId, "https://mock.mtn.ci/pay/" + mockId,
|
||||
Instant.now().plusSeconds(600), Map.of("mock", "true", "provider", CODE));
|
||||
}
|
||||
// TODO P1.3 Phase 3 : implémenter MTN Collection API (requestToPay)
|
||||
throw new PaymentException(CODE, "MTN MoMo non encore implémenté en production", 501);
|
||||
}
|
||||
|
||||
@Override
|
||||
public PaymentStatus getStatus(String externalId) throws PaymentException {
|
||||
log.warn("MTN MoMo getStatus mock pour externalId={}", externalId);
|
||||
return PaymentStatus.PROCESSING;
|
||||
}
|
||||
|
||||
@Override
|
||||
public PaymentEvent processWebhook(String rawBody, Map<String, String> headers) throws PaymentException {
|
||||
// TODO P1.3 Phase 3 : parser callback MTN MoMo
|
||||
throw new PaymentException(CODE, "Webhook MTN MoMo non encore implémenté", 501);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isAvailable() {
|
||||
return subscriptionKey != null && !subscriptionKey.isBlank();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package dev.lions.unionflow.server.payment.orangemoney;
|
||||
|
||||
import dev.lions.unionflow.server.api.payment.*;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.eclipse.microprofile.config.inject.ConfigProperty;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Provider Orange Money (stub — à implémenter avec l'API Orange Money WebPay).
|
||||
*
|
||||
* <p>Sandbox : https://developer.orange.com/apis/om-webpay
|
||||
* Requis : client_id, client_secret, merchant_key par pays.
|
||||
*
|
||||
* <p>Retourne un mock tant que {@code orange.api.client-id} n'est pas configuré.
|
||||
*/
|
||||
@Slf4j
|
||||
@ApplicationScoped
|
||||
public class OrangeMoneyPaymentProvider implements PaymentProvider {
|
||||
|
||||
public static final String CODE = "ORANGE_MONEY";
|
||||
|
||||
@ConfigProperty(name = "orange.api.client-id")
|
||||
Optional<String> clientIdOpt;
|
||||
|
||||
@ConfigProperty(name = "orange.api.base-url", defaultValue = "https://api.orange.com/orange-money-webpay/dev/v1")
|
||||
String baseUrl;
|
||||
|
||||
String clientId;
|
||||
|
||||
@jakarta.annotation.PostConstruct
|
||||
void init() {
|
||||
clientId = clientIdOpt.orElse("");
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getProviderCode() {
|
||||
return CODE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public CheckoutSession initiateCheckout(CheckoutRequest request) throws PaymentException {
|
||||
if (clientId == null || clientId.isBlank()) {
|
||||
log.warn("Orange Money non configuré — mode mock actif pour ref={}", request.reference());
|
||||
String mockId = "OM-MOCK-" + UUID.randomUUID().toString().substring(0, 8).toUpperCase();
|
||||
return new CheckoutSession(mockId, "https://mock.orange.ci/pay/" + mockId,
|
||||
Instant.now().plusSeconds(900), Map.of("mock", "true", "provider", CODE));
|
||||
}
|
||||
// TODO P1.3 Phase 3 : implémenter OAuth2 + POST /webpay
|
||||
throw new PaymentException(CODE, "Orange Money non encore implémenté en production", 501);
|
||||
}
|
||||
|
||||
@Override
|
||||
public PaymentStatus getStatus(String externalId) throws PaymentException {
|
||||
log.warn("Orange Money getStatus mock pour externalId={}", externalId);
|
||||
return PaymentStatus.PROCESSING;
|
||||
}
|
||||
|
||||
@Override
|
||||
public PaymentEvent processWebhook(String rawBody, Map<String, String> headers) throws PaymentException {
|
||||
// TODO P1.3 Phase 3 : parser webhook Orange Money + vérifier signature
|
||||
throw new PaymentException(CODE, "Webhook Orange Money non encore implémenté", 501);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isAvailable() {
|
||||
return clientId != null && !clientId.isBlank();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package dev.lions.unionflow.server.payment.orchestration;
|
||||
|
||||
import dev.lions.unionflow.server.api.payment.*;
|
||||
import dev.lions.unionflow.server.service.PaiementService;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.inject.Inject;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.eclipse.microprofile.config.inject.ConfigProperty;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Façade de paiement avec stratégie de fallback automatique.
|
||||
*
|
||||
* <p>Ordre de priorité :
|
||||
* <ol>
|
||||
* <li>PI-SPI si disponible (obligation réglementaire BCEAO)</li>
|
||||
* <li>Provider demandé par le client</li>
|
||||
* <li>Wave (provider par défaut)</li>
|
||||
* </ol>
|
||||
*/
|
||||
@Slf4j
|
||||
@ApplicationScoped
|
||||
public class PaymentOrchestrator {
|
||||
|
||||
@Inject
|
||||
PaymentProviderRegistry registry;
|
||||
|
||||
@Inject
|
||||
PaiementService paiementService;
|
||||
|
||||
@ConfigProperty(name = "payment.default-provider", defaultValue = "WAVE")
|
||||
String defaultProvider;
|
||||
|
||||
@ConfigProperty(name = "payment.pispi-priority", defaultValue = "false")
|
||||
boolean pispiPriority;
|
||||
|
||||
/**
|
||||
* Lance un checkout sur le provider demandé, avec fallback si indisponible.
|
||||
*
|
||||
* @param request la requête de checkout
|
||||
* @param providerCode le provider demandé (null = provider par défaut)
|
||||
*/
|
||||
public CheckoutSession initierPaiement(CheckoutRequest request, String providerCode) throws PaymentException {
|
||||
List<String> ordre = buildProviderOrder(providerCode);
|
||||
PaymentException dernierEchec = null;
|
||||
|
||||
for (String code : ordre) {
|
||||
PaymentProvider provider = tryGetProvider(code);
|
||||
if (provider == null || !provider.isAvailable()) continue;
|
||||
|
||||
try {
|
||||
CheckoutSession session = provider.initiateCheckout(request);
|
||||
log.info("Checkout initié via {} pour ref={}", code, request.reference());
|
||||
return session;
|
||||
} catch (PaymentException e) {
|
||||
log.warn("Provider {} échoué pour ref={}: {} — tentative fallback",
|
||||
code, request.reference(), e.getMessage());
|
||||
dernierEchec = e;
|
||||
}
|
||||
}
|
||||
|
||||
throw dernierEchec != null ? dernierEchec
|
||||
: new PaymentException("NONE", "Aucun provider de paiement disponible", 503);
|
||||
}
|
||||
|
||||
/**
|
||||
* Traite un événement de paiement reçu via webhook.
|
||||
* Délègue la mise à jour métier (souscription, cotisation...) selon la référence.
|
||||
*/
|
||||
public void handleEvent(PaymentEvent event) {
|
||||
log.info("PaymentEvent reçu : externalId={}, ref={}, statut={}",
|
||||
event.externalId(), event.reference(), event.status());
|
||||
paiementService.mettreAJourStatutDepuisWebhook(event);
|
||||
}
|
||||
|
||||
private List<String> buildProviderOrder(String requested) {
|
||||
if (pispiPriority) {
|
||||
if (requested != null) return List.of("PISPI", requested, defaultProvider);
|
||||
return List.of("PISPI", defaultProvider);
|
||||
}
|
||||
if (requested != null) return List.of(requested, defaultProvider);
|
||||
return List.of(defaultProvider);
|
||||
}
|
||||
|
||||
private PaymentProvider tryGetProvider(String code) {
|
||||
try {
|
||||
return registry.get(code);
|
||||
} catch (UnsupportedOperationException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package dev.lions.unionflow.server.payment.orchestration;
|
||||
|
||||
import dev.lions.unionflow.server.api.payment.PaymentProvider;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.enterprise.inject.Any;
|
||||
import jakarta.enterprise.inject.Instance;
|
||||
import jakarta.inject.Inject;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.StreamSupport;
|
||||
|
||||
/**
|
||||
* Registry CDI des providers de paiement disponibles.
|
||||
* Résout dynamiquement le bon provider par son code.
|
||||
*/
|
||||
@ApplicationScoped
|
||||
public class PaymentProviderRegistry {
|
||||
|
||||
@Inject
|
||||
@Any
|
||||
Instance<PaymentProvider> providers;
|
||||
|
||||
/**
|
||||
* Retourne le provider identifié par {@code code}.
|
||||
*
|
||||
* @throws UnsupportedOperationException si aucun provider n'est enregistré pour ce code
|
||||
*/
|
||||
public PaymentProvider get(String code) {
|
||||
return StreamSupport.stream(providers.spliterator(), false)
|
||||
.filter(p -> p.getProviderCode().equalsIgnoreCase(code))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new UnsupportedOperationException(
|
||||
"Provider de paiement non supporté : " + code));
|
||||
}
|
||||
|
||||
/** Retourne tous les providers disponibles. */
|
||||
public List<PaymentProvider> getAll() {
|
||||
return StreamSupport.stream(providers.spliterator(), false)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/** Retourne les codes de tous les providers disponibles. */
|
||||
public List<String> getAvailableCodes() {
|
||||
return getAll().stream().map(PaymentProvider::getProviderCode).collect(Collectors.toList());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package dev.lions.unionflow.server.payment.pispi;
|
||||
|
||||
import dev.lions.unionflow.server.api.payment.PaymentException;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.json.Json;
|
||||
import jakarta.json.JsonObject;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.eclipse.microprofile.config.inject.ConfigProperty;
|
||||
|
||||
import java.io.StringReader;
|
||||
import java.net.URI;
|
||||
import java.net.URLEncoder;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
import java.util.Optional;
|
||||
|
||||
@Slf4j
|
||||
@ApplicationScoped
|
||||
public class PispiAuth {
|
||||
|
||||
@ConfigProperty(name = "pispi.api.client-id")
|
||||
Optional<String> clientIdOpt;
|
||||
|
||||
@ConfigProperty(name = "pispi.api.client-secret")
|
||||
Optional<String> clientSecretOpt;
|
||||
|
||||
String clientId;
|
||||
String clientSecret;
|
||||
|
||||
@jakarta.annotation.PostConstruct
|
||||
void init() {
|
||||
clientId = clientIdOpt.orElse("");
|
||||
clientSecret = clientSecretOpt.orElse("");
|
||||
}
|
||||
|
||||
@ConfigProperty(name = "pispi.api.base-url", defaultValue = "https://sandbox.pispi.bceao.int/business-api/v1")
|
||||
String baseUrl;
|
||||
|
||||
private String cachedToken;
|
||||
private Instant cacheExpiry;
|
||||
|
||||
public synchronized String getAccessToken() throws PaymentException {
|
||||
if (cachedToken != null && Instant.now().isBefore(cacheExpiry)) {
|
||||
return cachedToken;
|
||||
}
|
||||
try {
|
||||
String body = "grant_type=client_credentials"
|
||||
+ "&client_id=" + URLEncoder.encode(clientId, StandardCharsets.UTF_8)
|
||||
+ "&client_secret=" + URLEncoder.encode(clientSecret, StandardCharsets.UTF_8)
|
||||
+ "&scope=pispi.transactions";
|
||||
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(baseUrl + "/oauth2/token"))
|
||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(body))
|
||||
.build();
|
||||
|
||||
HttpResponse<String> response = HttpClient.newHttpClient()
|
||||
.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
|
||||
if (response.statusCode() >= 400) {
|
||||
throw new PaymentException("PISPI",
|
||||
"Erreur OAuth2 PI-SPI HTTP " + response.statusCode() + " : " + response.body(),
|
||||
503);
|
||||
}
|
||||
|
||||
JsonObject json = Json.createReader(new StringReader(response.body())).readObject();
|
||||
cachedToken = json.getString("access_token");
|
||||
int expiresIn = json.getInt("expires_in", 3600);
|
||||
cacheExpiry = Instant.now().plusSeconds(expiresIn - 60);
|
||||
|
||||
log.debug("Token PI-SPI obtenu, expire dans {}s", expiresIn - 60);
|
||||
return cachedToken;
|
||||
} catch (PaymentException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
throw new PaymentException("PISPI", "Erreur OAuth2 PI-SPI : " + e.getMessage(), 503, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package dev.lions.unionflow.server.payment.pispi;
|
||||
|
||||
import dev.lions.unionflow.server.api.payment.PaymentException;
|
||||
import dev.lions.unionflow.server.payment.pispi.dto.Pacs002Response;
|
||||
import dev.lions.unionflow.server.payment.pispi.dto.Pacs008Request;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.inject.Inject;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.eclipse.microprofile.config.inject.ConfigProperty;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.util.Optional;
|
||||
|
||||
@Slf4j
|
||||
@ApplicationScoped
|
||||
public class PispiClient {
|
||||
|
||||
@Inject
|
||||
PispiAuth pispiAuth;
|
||||
|
||||
@ConfigProperty(name = "pispi.api.base-url", defaultValue = "https://sandbox.pispi.bceao.int/business-api/v1")
|
||||
String baseUrl;
|
||||
|
||||
@ConfigProperty(name = "pispi.institution.code")
|
||||
Optional<String> institutionCodeOpt;
|
||||
|
||||
String institutionCode;
|
||||
|
||||
@jakarta.annotation.PostConstruct
|
||||
void init() {
|
||||
institutionCode = institutionCodeOpt.orElse("");
|
||||
}
|
||||
|
||||
public Pacs002Response initiatePayment(Pacs008Request request) throws PaymentException {
|
||||
try {
|
||||
String token = pispiAuth.getAccessToken();
|
||||
String xmlBody = request.toXml();
|
||||
|
||||
log.debug("PI-SPI initiatePayment endToEndId={}", request.getEndToEndId());
|
||||
|
||||
HttpRequest httpRequest = HttpRequest.newBuilder()
|
||||
.uri(URI.create(baseUrl + "/transactions/initiate"))
|
||||
.header("Content-Type", "application/xml")
|
||||
.header("Authorization", "Bearer " + token)
|
||||
.header("X-Institution-Code", institutionCode)
|
||||
.POST(HttpRequest.BodyPublishers.ofString(xmlBody))
|
||||
.build();
|
||||
|
||||
HttpResponse<String> response = HttpClient.newHttpClient()
|
||||
.send(httpRequest, HttpResponse.BodyHandlers.ofString());
|
||||
|
||||
int status = response.statusCode();
|
||||
if (status >= 400) {
|
||||
throw new PaymentException("PISPI", "Erreur PI-SPI HTTP " + status, status);
|
||||
}
|
||||
|
||||
return Pacs002Response.fromXml(response.body());
|
||||
} catch (PaymentException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
throw new PaymentException("PISPI", "Erreur lors de l'initiation du paiement PI-SPI : " + e.getMessage(), 503, e);
|
||||
}
|
||||
}
|
||||
|
||||
public Pacs002Response getStatus(String transactionId) throws PaymentException {
|
||||
try {
|
||||
String token = pispiAuth.getAccessToken();
|
||||
|
||||
log.debug("PI-SPI getStatus transactionId={}", transactionId);
|
||||
|
||||
HttpRequest httpRequest = HttpRequest.newBuilder()
|
||||
.uri(URI.create(baseUrl + "/transactions/" + transactionId))
|
||||
.header("Authorization", "Bearer " + token)
|
||||
.header("X-Institution-Code", institutionCode)
|
||||
.GET()
|
||||
.build();
|
||||
|
||||
HttpResponse<String> response = HttpClient.newHttpClient()
|
||||
.send(httpRequest, HttpResponse.BodyHandlers.ofString());
|
||||
|
||||
int status = response.statusCode();
|
||||
if (status >= 400) {
|
||||
throw new PaymentException("PISPI", "Erreur PI-SPI HTTP " + status, status);
|
||||
}
|
||||
|
||||
return Pacs002Response.fromXml(response.body());
|
||||
} catch (PaymentException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
throw new PaymentException("PISPI", "Erreur lors de la récupération du statut PI-SPI : " + e.getMessage(), 503, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package dev.lions.unionflow.server.payment.pispi;
|
||||
|
||||
import dev.lions.unionflow.server.api.payment.CheckoutRequest;
|
||||
import dev.lions.unionflow.server.api.payment.PaymentEvent;
|
||||
import dev.lions.unionflow.server.api.payment.PaymentStatus;
|
||||
import dev.lions.unionflow.server.payment.pispi.dto.Pacs002Response;
|
||||
import dev.lions.unionflow.server.payment.pispi.dto.Pacs008Request;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.UUID;
|
||||
|
||||
@ApplicationScoped
|
||||
public class PispiIso20022Mapper {
|
||||
|
||||
public Pacs008Request toPacs008(CheckoutRequest req, String institutionBic) {
|
||||
Pacs008Request pacs = new Pacs008Request();
|
||||
|
||||
pacs.setMessageId("UFMSG-" + UUID.randomUUID().toString().replace("-", "").substring(0, 16).toUpperCase());
|
||||
pacs.setCreationDateTime(DateTimeFormatter.ISO_INSTANT.format(Instant.now()));
|
||||
pacs.setNumberOfTransactions("1");
|
||||
|
||||
// ISO 20022 : EndToEndId max 35 chars
|
||||
String ref = req.reference();
|
||||
pacs.setEndToEndId(ref.length() > 35 ? ref.substring(0, 35) : ref);
|
||||
|
||||
pacs.setInstrId("UFINS-" + UUID.randomUUID().toString().substring(0, 8).toUpperCase());
|
||||
pacs.setAmount(req.amount());
|
||||
pacs.setCurrency(req.currency());
|
||||
|
||||
String customerName = req.metadata() != null
|
||||
? req.metadata().getOrDefault("customerName", "MEMBRE UNIONFLOW")
|
||||
: "MEMBRE UNIONFLOW";
|
||||
pacs.setDebtorName(customerName);
|
||||
pacs.setDebtorBic(institutionBic);
|
||||
|
||||
String creditorName = req.metadata() != null
|
||||
? req.metadata().getOrDefault("creditorName", "ORGANISATION UNIONFLOW")
|
||||
: "ORGANISATION UNIONFLOW";
|
||||
pacs.setCreditorName(creditorName);
|
||||
pacs.setCreditorBic(institutionBic);
|
||||
|
||||
// ISO 20022 : RemittanceInfo max 140 chars
|
||||
pacs.setRemittanceInfo(ref.length() > 140 ? ref.substring(0, 140) : ref);
|
||||
|
||||
return pacs;
|
||||
}
|
||||
|
||||
public PaymentStatus fromPacs002Status(String isoCode) {
|
||||
return switch (isoCode) {
|
||||
case "ACSC" -> PaymentStatus.SUCCESS;
|
||||
case "ACSP" -> PaymentStatus.PROCESSING;
|
||||
case "RJCT" -> PaymentStatus.FAILED;
|
||||
case "PDNG" -> PaymentStatus.INITIATED;
|
||||
default -> PaymentStatus.PROCESSING;
|
||||
};
|
||||
}
|
||||
|
||||
public PaymentEvent fromPacs002(Pacs002Response resp) {
|
||||
return new PaymentEvent(
|
||||
resp.getClearingSystemReference(),
|
||||
resp.getOriginalEndToEndId(),
|
||||
fromPacs002Status(resp.getTransactionStatus()),
|
||||
null,
|
||||
resp.getClearingSystemReference(),
|
||||
resp.getAcceptanceDateTime() != null ? resp.getAcceptanceDateTime() : Instant.now()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package dev.lions.unionflow.server.payment.pispi;
|
||||
|
||||
import dev.lions.unionflow.server.api.payment.CheckoutRequest;
|
||||
import dev.lions.unionflow.server.api.payment.CheckoutSession;
|
||||
import dev.lions.unionflow.server.api.payment.PaymentEvent;
|
||||
import dev.lions.unionflow.server.api.payment.PaymentException;
|
||||
import dev.lions.unionflow.server.api.payment.PaymentProvider;
|
||||
import dev.lions.unionflow.server.api.payment.PaymentStatus;
|
||||
import dev.lions.unionflow.server.payment.pispi.dto.Pacs002Response;
|
||||
import dev.lions.unionflow.server.payment.pispi.dto.Pacs008Request;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.inject.Inject;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.eclipse.microprofile.config.inject.ConfigProperty;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Provider PI-SPI BCEAO — interopérabilité paiements instantanés UEMOA.
|
||||
*
|
||||
* <p>Sandbox : https://developer.pispi.bceao.int
|
||||
* Spec : Business API ISO 20022 pacs.008.001.10 / pacs.002.001.14
|
||||
* Deadline obligation réglementaire : 30 juin 2026
|
||||
*
|
||||
* <p>Mode mock automatique si {@code pispi.api.client-id} ou {@code pispi.institution.code} sont absents.
|
||||
*/
|
||||
@Slf4j
|
||||
@ApplicationScoped
|
||||
public class PispiPaymentProvider implements PaymentProvider {
|
||||
|
||||
public static final String CODE = "PISPI";
|
||||
|
||||
@Inject
|
||||
PispiClient pispiClient;
|
||||
|
||||
@Inject
|
||||
PispiIso20022Mapper mapper;
|
||||
|
||||
@ConfigProperty(name = "pispi.api.client-id")
|
||||
java.util.Optional<String> clientIdOpt;
|
||||
|
||||
@ConfigProperty(name = "pispi.institution.code")
|
||||
java.util.Optional<String> institutionCodeOpt;
|
||||
|
||||
@ConfigProperty(name = "pispi.institution.bic", defaultValue = "")
|
||||
String institutionBic;
|
||||
|
||||
String clientId;
|
||||
String institutionCode;
|
||||
|
||||
@jakarta.annotation.PostConstruct
|
||||
void init() {
|
||||
clientId = clientIdOpt.orElse("");
|
||||
institutionCode = institutionCodeOpt.orElse("");
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getProviderCode() {
|
||||
return CODE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public CheckoutSession initiateCheckout(CheckoutRequest request) throws PaymentException {
|
||||
if (!isConfigured()) {
|
||||
String mockId = "PISPI-MOCK-" + UUID.randomUUID().toString().substring(0, 8).toUpperCase();
|
||||
log.warn("PI-SPI non configuré — mode mock pour ref={}", request.reference());
|
||||
return new CheckoutSession(
|
||||
mockId,
|
||||
"https://mock.pispi.bceao.int/pay/" + mockId,
|
||||
Instant.now().plusSeconds(1800),
|
||||
Map.of("mock", "true", "provider", CODE)
|
||||
);
|
||||
}
|
||||
Pacs008Request pacs008 = mapper.toPacs008(request, institutionBic);
|
||||
Pacs002Response pacs002 = pispiClient.initiatePayment(pacs008);
|
||||
String externalId = pacs002.getClearingSystemReference() != null
|
||||
? pacs002.getClearingSystemReference()
|
||||
: pacs008.getEndToEndId();
|
||||
return new CheckoutSession(
|
||||
externalId,
|
||||
null,
|
||||
Instant.now().plusSeconds(1800),
|
||||
Map.of("provider", CODE, "iso", "pacs.008.001.10", "endToEndId", pacs008.getEndToEndId())
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public PaymentStatus getStatus(String externalId) throws PaymentException {
|
||||
if (!isConfigured()) {
|
||||
log.warn("PI-SPI non configuré — getStatus mock pour id={}", externalId);
|
||||
return PaymentStatus.PROCESSING;
|
||||
}
|
||||
Pacs002Response pacs002 = pispiClient.getStatus(externalId);
|
||||
return mapper.fromPacs002Status(pacs002.getTransactionStatus());
|
||||
}
|
||||
|
||||
@Override
|
||||
public PaymentEvent processWebhook(String rawBody, Map<String, String> headers) throws PaymentException {
|
||||
// Les webhooks PI-SPI passent par PispiWebhookResource qui valide l'IP et la signature en amont
|
||||
throw new PaymentException(CODE, "Utiliser /api/pispi/webhook directement", 400);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isAvailable() {
|
||||
return isConfigured();
|
||||
}
|
||||
|
||||
private boolean isConfigured() {
|
||||
return clientId != null && !clientId.isBlank()
|
||||
&& institutionCode != null && !institutionCode.isBlank();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package dev.lions.unionflow.server.payment.pispi;
|
||||
|
||||
import dev.lions.unionflow.server.api.payment.PaymentException;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import org.eclipse.microprofile.config.inject.ConfigProperty;
|
||||
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.Arrays;
|
||||
import java.util.HexFormat;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
@ApplicationScoped
|
||||
public class PispiSignatureVerifier {
|
||||
|
||||
@ConfigProperty(name = "pispi.webhook.secret")
|
||||
Optional<String> webhookSecretOpt;
|
||||
|
||||
@ConfigProperty(name = "pispi.webhook.allowed-ips")
|
||||
Optional<String> allowedIpsOpt;
|
||||
|
||||
String webhookSecret;
|
||||
String allowedIps;
|
||||
|
||||
@jakarta.annotation.PostConstruct
|
||||
void init() {
|
||||
webhookSecret = webhookSecretOpt.orElse("");
|
||||
allowedIps = allowedIpsOpt.orElse("");
|
||||
}
|
||||
|
||||
public boolean isIpAllowed(String ip) {
|
||||
if (allowedIps == null || allowedIps.isBlank()) {
|
||||
return true;
|
||||
}
|
||||
return Arrays.asList(allowedIps.split(",")).stream()
|
||||
.map(String::trim)
|
||||
.anyMatch(allowed -> allowed.equals(ip));
|
||||
}
|
||||
|
||||
public boolean verifySignature(String rawBody, Map<String, String> headers) throws PaymentException {
|
||||
if (webhookSecret == null || webhookSecret.isBlank()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Recherche insensible à la casse
|
||||
String receivedSignature = headers.entrySet().stream()
|
||||
.filter(e -> "X-PISPI-Signature".equalsIgnoreCase(e.getKey()))
|
||||
.map(Map.Entry::getValue)
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
|
||||
if (receivedSignature == null) {
|
||||
throw new PaymentException("PISPI", "Signature PI-SPI absente", 401);
|
||||
}
|
||||
|
||||
try {
|
||||
Mac mac = Mac.getInstance("HmacSHA256");
|
||||
mac.init(new SecretKeySpec(webhookSecret.getBytes(), "HmacSHA256"));
|
||||
String computed = HexFormat.of().formatHex(mac.doFinal(rawBody.getBytes()));
|
||||
|
||||
if (!MessageDigest.isEqual(computed.getBytes(), receivedSignature.getBytes())) {
|
||||
throw new PaymentException("PISPI", "Signature PI-SPI invalide", 401);
|
||||
}
|
||||
return true;
|
||||
} catch (PaymentException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
throw new PaymentException("PISPI", "Erreur lors de la vérification de signature : " + e.getMessage(), 500, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package dev.lions.unionflow.server.payment.pispi;
|
||||
|
||||
import dev.lions.unionflow.server.api.payment.PaymentEvent;
|
||||
import dev.lions.unionflow.server.api.payment.PaymentException;
|
||||
import dev.lions.unionflow.server.payment.orchestration.PaymentOrchestrator;
|
||||
import dev.lions.unionflow.server.payment.pispi.dto.Pacs002Response;
|
||||
import jakarta.annotation.security.PermitAll;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.DefaultValue;
|
||||
import jakarta.ws.rs.HeaderParam;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.core.Context;
|
||||
import jakarta.ws.rs.core.HttpHeaders;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Slf4j
|
||||
@Path("/api/pispi/webhook")
|
||||
public class PispiWebhookResource {
|
||||
|
||||
@Inject
|
||||
PispiSignatureVerifier verifier;
|
||||
|
||||
@Inject
|
||||
PispiIso20022Mapper mapper;
|
||||
|
||||
@Inject
|
||||
PaymentOrchestrator orchestrator;
|
||||
|
||||
@POST
|
||||
@Consumes("application/xml")
|
||||
@PermitAll
|
||||
public Response recevoir(
|
||||
String rawXmlBody,
|
||||
@Context HttpHeaders headers,
|
||||
@HeaderParam("X-Forwarded-For") @DefaultValue("") String forwardedFor) {
|
||||
|
||||
String clientIp = forwardedFor.isBlank() ? "unknown" : forwardedFor.split(",")[0].trim();
|
||||
|
||||
if (!verifier.isIpAllowed(clientIp)) {
|
||||
log.warn("PI-SPI webhook refusé — IP non autorisée : {}", clientIp);
|
||||
return Response.status(403).entity("IP non autorisée").build();
|
||||
}
|
||||
|
||||
Map<String, String> headersMap = headers.getRequestHeaders().entrySet().stream()
|
||||
.collect(Collectors.toMap(Map.Entry::getKey, e -> e.getValue().get(0)));
|
||||
|
||||
try {
|
||||
verifier.verifySignature(rawXmlBody, headersMap);
|
||||
} catch (PaymentException e) {
|
||||
log.warn("PI-SPI webhook — échec vérification signature : {}", e.getMessage());
|
||||
return Response.status(401).entity(e.getMessage()).build();
|
||||
}
|
||||
|
||||
try {
|
||||
Pacs002Response pacs002 = Pacs002Response.fromXml(rawXmlBody);
|
||||
PaymentEvent event = mapper.fromPacs002(pacs002);
|
||||
orchestrator.handleEvent(event);
|
||||
log.info("PI-SPI webhook traité : ref={}, statut={}", event.reference(), event.status());
|
||||
return Response.ok().build();
|
||||
} catch (Exception e) {
|
||||
log.error("PI-SPI webhook — erreur traitement : {}", e.getMessage(), e);
|
||||
return Response.serverError().entity("Erreur interne").build();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package dev.lions.unionflow.server.payment.pispi.dto;
|
||||
|
||||
import org.w3c.dom.Document;
|
||||
import org.w3c.dom.NodeList;
|
||||
import org.xml.sax.InputSource;
|
||||
|
||||
import javax.xml.parsers.DocumentBuilder;
|
||||
import javax.xml.parsers.DocumentBuilderFactory;
|
||||
import java.io.StringReader;
|
||||
import java.time.Instant;
|
||||
|
||||
public class Pacs002Response {
|
||||
|
||||
private String originalMessageId;
|
||||
private String originalEndToEndId;
|
||||
private String transactionStatus;
|
||||
private String rejectReasonCode;
|
||||
private String clearingSystemReference;
|
||||
private Instant acceptanceDateTime;
|
||||
|
||||
public Pacs002Response() {}
|
||||
|
||||
public String getOriginalMessageId() { return originalMessageId; }
|
||||
public void setOriginalMessageId(String originalMessageId) { this.originalMessageId = originalMessageId; }
|
||||
|
||||
public String getOriginalEndToEndId() { return originalEndToEndId; }
|
||||
public void setOriginalEndToEndId(String originalEndToEndId) { this.originalEndToEndId = originalEndToEndId; }
|
||||
|
||||
public String getTransactionStatus() { return transactionStatus; }
|
||||
public void setTransactionStatus(String transactionStatus) { this.transactionStatus = transactionStatus; }
|
||||
|
||||
public String getRejectReasonCode() { return rejectReasonCode; }
|
||||
public void setRejectReasonCode(String rejectReasonCode) { this.rejectReasonCode = rejectReasonCode; }
|
||||
|
||||
public String getClearingSystemReference() { return clearingSystemReference; }
|
||||
public void setClearingSystemReference(String clearingSystemReference) { this.clearingSystemReference = clearingSystemReference; }
|
||||
|
||||
public Instant getAcceptanceDateTime() { return acceptanceDateTime; }
|
||||
public void setAcceptanceDateTime(Instant acceptanceDateTime) { this.acceptanceDateTime = acceptanceDateTime; }
|
||||
|
||||
public static Pacs002Response fromXml(String xml) {
|
||||
Pacs002Response response = new Pacs002Response();
|
||||
try {
|
||||
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
|
||||
factory.setNamespaceAware(false);
|
||||
// Désactiver les entités externes (OWASP XXE)
|
||||
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
|
||||
DocumentBuilder builder = factory.newDocumentBuilder();
|
||||
Document doc = builder.parse(new InputSource(new StringReader(xml)));
|
||||
doc.getDocumentElement().normalize();
|
||||
|
||||
response.setOriginalEndToEndId(firstText(doc, "OrgnlEndToEndId"));
|
||||
response.setTransactionStatus(firstText(doc, "TxSts"));
|
||||
response.setRejectReasonCode(firstText(doc, "RsnCd"));
|
||||
response.setClearingSystemReference(firstText(doc, "ClrSysRef"));
|
||||
|
||||
String acptDtTm = firstText(doc, "AccptncDtTm");
|
||||
if (acptDtTm != null && !acptDtTm.isBlank()) {
|
||||
try {
|
||||
response.setAcceptanceDateTime(Instant.parse(acptDtTm));
|
||||
} catch (Exception ignored) {
|
||||
// format non parsable — on laisse null
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
throw new IllegalArgumentException("Impossible de parser le pacs.002 XML : " + e.getMessage(), e);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
private static String firstText(Document doc, String tagName) {
|
||||
NodeList nodes = doc.getElementsByTagName(tagName);
|
||||
if (nodes.getLength() > 0) {
|
||||
String text = nodes.item(0).getTextContent();
|
||||
return (text == null || text.isBlank()) ? null : text.trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package dev.lions.unionflow.server.payment.pispi.dto;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
public class Pacs008Request {
|
||||
|
||||
private String messageId;
|
||||
private String creationDateTime;
|
||||
private String numberOfTransactions;
|
||||
private String endToEndId;
|
||||
private String instrId;
|
||||
private BigDecimal amount;
|
||||
private String currency;
|
||||
private String debtorName;
|
||||
private String debtorBic;
|
||||
private String creditorName;
|
||||
private String creditorBic;
|
||||
private String creditorIban;
|
||||
private String remittanceInfo;
|
||||
|
||||
public Pacs008Request() {}
|
||||
|
||||
public String getMessageId() { return messageId; }
|
||||
public void setMessageId(String messageId) { this.messageId = messageId; }
|
||||
|
||||
public String getCreationDateTime() { return creationDateTime; }
|
||||
public void setCreationDateTime(String creationDateTime) { this.creationDateTime = creationDateTime; }
|
||||
|
||||
public String getNumberOfTransactions() { return numberOfTransactions; }
|
||||
public void setNumberOfTransactions(String numberOfTransactions) { this.numberOfTransactions = numberOfTransactions; }
|
||||
|
||||
public String getEndToEndId() { return endToEndId; }
|
||||
public void setEndToEndId(String endToEndId) { this.endToEndId = endToEndId; }
|
||||
|
||||
public String getInstrId() { return instrId; }
|
||||
public void setInstrId(String instrId) { this.instrId = instrId; }
|
||||
|
||||
public BigDecimal getAmount() { return amount; }
|
||||
public void setAmount(BigDecimal amount) { this.amount = amount; }
|
||||
|
||||
public String getCurrency() { return currency; }
|
||||
public void setCurrency(String currency) { this.currency = currency; }
|
||||
|
||||
public String getDebtorName() { return debtorName; }
|
||||
public void setDebtorName(String debtorName) { this.debtorName = debtorName; }
|
||||
|
||||
public String getDebtorBic() { return debtorBic; }
|
||||
public void setDebtorBic(String debtorBic) { this.debtorBic = debtorBic; }
|
||||
|
||||
public String getCreditorName() { return creditorName; }
|
||||
public void setCreditorName(String creditorName) { this.creditorName = creditorName; }
|
||||
|
||||
public String getCreditorBic() { return creditorBic; }
|
||||
public void setCreditorBic(String creditorBic) { this.creditorBic = creditorBic; }
|
||||
|
||||
public String getCreditorIban() { return creditorIban; }
|
||||
public void setCreditorIban(String creditorIban) { this.creditorIban = creditorIban; }
|
||||
|
||||
public String getRemittanceInfo() { return remittanceInfo; }
|
||||
public void setRemittanceInfo(String remittanceInfo) { this.remittanceInfo = remittanceInfo; }
|
||||
|
||||
public String toXml() {
|
||||
return "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" +
|
||||
"<Document xmlns=\"urn:iso:std:iso:20022:tech:xsd:pacs.008.001.10\">\n" +
|
||||
" <FIToFICstmrCdtTrf>\n" +
|
||||
" <GrpHdr>\n" +
|
||||
" <MsgId>" + escape(messageId) + "</MsgId>\n" +
|
||||
" <CreDtTm>" + escape(creationDateTime) + "</CreDtTm>\n" +
|
||||
" <NbOfTxs>1</NbOfTxs>\n" +
|
||||
" </GrpHdr>\n" +
|
||||
" <CdtTrfTxInf>\n" +
|
||||
" <PmtId>\n" +
|
||||
" <InstrId>" + escape(instrId) + "</InstrId>\n" +
|
||||
" <EndToEndId>" + escape(endToEndId) + "</EndToEndId>\n" +
|
||||
" </PmtId>\n" +
|
||||
" <IntrBkSttlmAmt Ccy=\"" + escape(currency) + "\">" + (amount != null ? amount.toPlainString() : "0") + "</IntrBkSttlmAmt>\n" +
|
||||
" <Dbtr><Nm>" + escape(debtorName) + "</Nm></Dbtr>\n" +
|
||||
" <DbtrAgt><FinInstnId><BICFI>" + escape(debtorBic) + "</BICFI></FinInstnId></DbtrAgt>\n" +
|
||||
" <Cdtr><Nm>" + escape(creditorName) + "</Nm></Cdtr>\n" +
|
||||
" <CdtrAgt><FinInstnId><BICFI>" + escape(creditorBic) + "</BICFI></FinInstnId></CdtrAgt>\n" +
|
||||
" <RmtInf><Ustrd>" + escape(remittanceInfo) + "</Ustrd></RmtInf>\n" +
|
||||
" </CdtTrfTxInf>\n" +
|
||||
" </FIToFICstmrCdtTrf>\n" +
|
||||
"</Document>";
|
||||
}
|
||||
|
||||
private static String escape(String value) {
|
||||
if (value == null) return "";
|
||||
return value
|
||||
.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("\"", """)
|
||||
.replace("'", "'");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package dev.lions.unionflow.server.payment.wave;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import dev.lions.unionflow.server.api.payment.*;
|
||||
import dev.lions.unionflow.server.service.WaveCheckoutService;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.inject.Inject;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.eclipse.microprofile.config.inject.ConfigProperty;
|
||||
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.util.HexFormat;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Implémentation Wave de PaymentProvider.
|
||||
*
|
||||
* <p>Délègue la création de session à {@link WaveCheckoutService} existant.
|
||||
* Normalise les webhooks Wave vers {@link PaymentEvent}.
|
||||
*/
|
||||
@Slf4j
|
||||
@ApplicationScoped
|
||||
public class WavePaymentProvider implements PaymentProvider {
|
||||
|
||||
public static final String CODE = "WAVE";
|
||||
|
||||
@Inject
|
||||
WaveCheckoutService waveCheckoutService;
|
||||
|
||||
@ConfigProperty(name = "wave.webhook.secret", defaultValue = "")
|
||||
String webhookSecret;
|
||||
|
||||
private final ObjectMapper mapper = new ObjectMapper();
|
||||
|
||||
@Override
|
||||
public String getProviderCode() {
|
||||
return CODE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public CheckoutSession initiateCheckout(CheckoutRequest request) throws PaymentException {
|
||||
try {
|
||||
String amount = request.amount().toBigInteger().toString();
|
||||
WaveCheckoutService.WaveCheckoutSessionResponse resp = waveCheckoutService.createSession(
|
||||
amount,
|
||||
request.currency(),
|
||||
request.successUrl(),
|
||||
request.cancelUrl(),
|
||||
request.reference(),
|
||||
request.customerPhone()
|
||||
);
|
||||
return new CheckoutSession(
|
||||
resp.id,
|
||||
resp.waveLaunchUrl,
|
||||
Instant.now().plusSeconds(3600),
|
||||
Map.of("provider", CODE)
|
||||
);
|
||||
} catch (Exception e) {
|
||||
throw new PaymentException(CODE, e.getMessage(), 500, e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public PaymentStatus getStatus(String externalId) throws PaymentException {
|
||||
// Wave ne fournit pas d'API de polling — le statut passe par les webhooks.
|
||||
// Un polling naïf via la session URL n'est pas supporté.
|
||||
log.warn("Wave ne supporte pas le polling de statut — utiliser les webhooks.");
|
||||
return PaymentStatus.PROCESSING;
|
||||
}
|
||||
|
||||
@Override
|
||||
public PaymentEvent processWebhook(String rawBody, Map<String, String> headers) throws PaymentException {
|
||||
verifierSignatureWave(rawBody, headers);
|
||||
|
||||
try {
|
||||
JsonNode root = mapper.readTree(rawBody);
|
||||
String type = root.path("type").asText();
|
||||
JsonNode data = root.path("data");
|
||||
|
||||
String externalId = data.path("id").asText(null);
|
||||
String clientRef = data.path("client_reference").asText(null);
|
||||
String rawAmount = data.path("amount").asText("0");
|
||||
BigDecimal amount = new BigDecimal(rawAmount);
|
||||
|
||||
PaymentStatus status = switch (type) {
|
||||
case "checkout.session.completed" -> PaymentStatus.SUCCESS;
|
||||
case "checkout.session.failed" -> PaymentStatus.FAILED;
|
||||
case "checkout.session.expired" -> PaymentStatus.EXPIRED;
|
||||
default -> PaymentStatus.PROCESSING;
|
||||
};
|
||||
|
||||
return new PaymentEvent(
|
||||
externalId,
|
||||
clientRef,
|
||||
status,
|
||||
amount,
|
||||
data.path("transaction_id").asText(null),
|
||||
Instant.now()
|
||||
);
|
||||
} catch (Exception e) {
|
||||
throw new PaymentException(CODE, "Webhook Wave malformé : " + e.getMessage(), 400, e);
|
||||
}
|
||||
}
|
||||
|
||||
private void verifierSignatureWave(String rawBody, Map<String, String> headers) throws PaymentException {
|
||||
if (webhookSecret == null || webhookSecret.isBlank()) return;
|
||||
|
||||
String sigHeader = headers.get("wave-signature");
|
||||
if (sigHeader == null) sigHeader = headers.get("Wave-Signature");
|
||||
if (sigHeader == null) {
|
||||
throw new PaymentException(CODE, "Signature webhook Wave absente", 401);
|
||||
}
|
||||
|
||||
try {
|
||||
String timestamp = "";
|
||||
String receivedSig = "";
|
||||
for (String part : sigHeader.split(",")) {
|
||||
if (part.startsWith("t=")) timestamp = part.substring(2);
|
||||
if (part.startsWith("v1=")) receivedSig = part.substring(3);
|
||||
}
|
||||
|
||||
String payload = timestamp + "." + rawBody;
|
||||
Mac mac = Mac.getInstance("HmacSHA256");
|
||||
mac.init(new SecretKeySpec(webhookSecret.getBytes(), "HmacSHA256"));
|
||||
String computed = HexFormat.of().formatHex(mac.doFinal(payload.getBytes()));
|
||||
|
||||
if (!java.security.MessageDigest.isEqual(computed.getBytes(), receivedSig.getBytes())) {
|
||||
throw new PaymentException(CODE, "Signature webhook Wave invalide", 401);
|
||||
}
|
||||
} catch (PaymentException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
throw new PaymentException(CODE, "Erreur vérification signature Wave : " + e.getMessage(), 500, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package dev.lions.unionflow.server.repository;
|
||||
|
||||
import dev.lions.unionflow.server.api.enums.membre.NiveauRisqueKyc;
|
||||
import dev.lions.unionflow.server.api.enums.membre.StatutKyc;
|
||||
import dev.lions.unionflow.server.entity.KycDossier;
|
||||
import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
@ApplicationScoped
|
||||
public class KycDossierRepository implements PanacheRepositoryBase<KycDossier, UUID> {
|
||||
|
||||
public Optional<KycDossier> findDossierActifByMembre(UUID membreId) {
|
||||
return find("membre.id = ?1 AND actif = true", membreId).firstResultOptional();
|
||||
}
|
||||
|
||||
public List<KycDossier> findByMembre(UUID membreId) {
|
||||
return find("membre.id = ?1 ORDER BY dateCreation DESC", membreId).list();
|
||||
}
|
||||
|
||||
public List<KycDossier> findByStatut(StatutKyc statut) {
|
||||
return find("statut = ?1 AND actif = true", statut).list();
|
||||
}
|
||||
|
||||
public List<KycDossier> findByNiveauRisque(NiveauRisqueKyc niveauRisque) {
|
||||
return find("niveauRisque = ?1 AND actif = true ORDER BY scoreRisque DESC", niveauRisque).list();
|
||||
}
|
||||
|
||||
public List<KycDossier> findPep() {
|
||||
return find("estPep = true AND actif = true").list();
|
||||
}
|
||||
|
||||
public List<KycDossier> findPiecesExpirantsAvant(LocalDate date) {
|
||||
return find("dateExpirationPiece <= ?1 AND actif = true ORDER BY dateExpirationPiece ASC", date).list();
|
||||
}
|
||||
|
||||
public long countByStatut(StatutKyc statut) {
|
||||
return count("statut = ?1 AND actif = true", statut);
|
||||
}
|
||||
|
||||
public long countPepActifs() {
|
||||
return count("estPep = true AND actif = true");
|
||||
}
|
||||
|
||||
public List<KycDossier> findByAnnee(int anneeReference) {
|
||||
return find("anneeReference = ?1", anneeReference).list();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package dev.lions.unionflow.server.repository.mutuelle;
|
||||
|
||||
import dev.lions.unionflow.server.entity.mutuelle.ParametresFinanciersMutuelle;
|
||||
import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
@ApplicationScoped
|
||||
public class ParametresFinanciersMutuellRepository implements PanacheRepositoryBase<ParametresFinanciersMutuelle, UUID> {
|
||||
|
||||
public Optional<ParametresFinanciersMutuelle> findByOrganisation(UUID orgId) {
|
||||
return find("organisation.id", orgId).firstResultOptional();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package dev.lions.unionflow.server.repository.mutuelle.parts;
|
||||
|
||||
import dev.lions.unionflow.server.entity.mutuelle.parts.ComptePartsSociales;
|
||||
import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
@ApplicationScoped
|
||||
public class ComptePartsSocialesRepository implements PanacheRepositoryBase<ComptePartsSociales, UUID> {
|
||||
|
||||
public Optional<ComptePartsSociales> findByNumeroCompte(String numeroCompte) {
|
||||
return find("numeroCompte", numeroCompte).firstResultOptional();
|
||||
}
|
||||
|
||||
public List<ComptePartsSociales> findByMembre(UUID membreId) {
|
||||
return list("membre.id = ?1 AND actif = true", membreId);
|
||||
}
|
||||
|
||||
public List<ComptePartsSociales> findByOrganisation(UUID orgId) {
|
||||
return list("organisation.id = ?1 AND actif = true ORDER BY dateCreation DESC", orgId);
|
||||
}
|
||||
|
||||
public Optional<ComptePartsSociales> findByMembreAndOrg(UUID membreId, UUID orgId) {
|
||||
return find("membre.id = ?1 AND organisation.id = ?2 AND actif = true", membreId, orgId)
|
||||
.firstResultOptional();
|
||||
}
|
||||
|
||||
public long countByOrganisation(UUID orgId) {
|
||||
return count("organisation.id = ?1 AND actif = true", orgId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package dev.lions.unionflow.server.repository.mutuelle.parts;
|
||||
|
||||
import dev.lions.unionflow.server.entity.mutuelle.parts.TransactionPartsSociales;
|
||||
import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@ApplicationScoped
|
||||
public class TransactionPartsSocialesRepository implements PanacheRepositoryBase<TransactionPartsSociales, UUID> {
|
||||
|
||||
public List<TransactionPartsSociales> findByCompte(UUID compteId) {
|
||||
return list("compte.id = ?1 ORDER BY dateTransaction DESC", compteId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package dev.lions.unionflow.server.resource;
|
||||
|
||||
import dev.lions.unionflow.server.service.MigrerOrganisationsVersKeycloakService;
|
||||
import dev.lions.unionflow.server.service.MigrerOrganisationsVersKeycloakService.MigrationReport;
|
||||
import jakarta.annotation.security.RolesAllowed;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Endpoints d'administration Keycloak 26 Organizations.
|
||||
*
|
||||
* <p>Réservés aux SUPER_ADMIN. Opérations à déclencher manuellement lors de la
|
||||
* migration Keycloak 23 → 26.
|
||||
*/
|
||||
@Slf4j
|
||||
@Path("/api/admin/keycloak")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@RolesAllowed("SUPER_ADMIN")
|
||||
public class AdminKeycloakOrganisationResource {
|
||||
|
||||
@Inject
|
||||
MigrerOrganisationsVersKeycloakService migrationService;
|
||||
|
||||
/**
|
||||
* Lance la migration one-shot des organisations UnionFlow vers Keycloak 26 Organizations.
|
||||
*
|
||||
* <p>Idempotent : les organisations déjà migrées (keycloak_org_id non null) sont ignorées.
|
||||
*
|
||||
* @return rapport de migration (total, créés, ignorés, erreurs)
|
||||
*/
|
||||
@POST
|
||||
@Path("/migrer-organisations")
|
||||
public Response migrerOrganisations() {
|
||||
log.info("Déclenchement migration organisations → Keycloak 26 Organizations");
|
||||
try {
|
||||
MigrationReport report = migrationService.migrerToutesLesOrganisations();
|
||||
log.info("Migration terminée : {}", report);
|
||||
|
||||
return Response
|
||||
.status(report.success() ? Response.Status.OK.getStatusCode() : 207)
|
||||
.entity(Map.of(
|
||||
"total", report.total(),
|
||||
"crees", report.crees(),
|
||||
"ignores", report.ignores(),
|
||||
"erreurs", report.erreurs(),
|
||||
"succes", report.success()
|
||||
))
|
||||
.build();
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Erreur critique lors de la migration : {}", e.getMessage(), e);
|
||||
return Response.serverError()
|
||||
.entity(Map.of("error", e.getMessage()))
|
||||
.build();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package dev.lions.unionflow.server.resource;
|
||||
|
||||
import dev.lions.unionflow.server.service.ComptabilitePdfService;
|
||||
import jakarta.annotation.security.RolesAllowed;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.ws.rs.*;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import java.time.LocalDate;
|
||||
import java.util.UUID;
|
||||
import org.eclipse.microprofile.openapi.annotations.Operation;
|
||||
import org.eclipse.microprofile.openapi.annotations.tags.Tag;
|
||||
|
||||
/**
|
||||
* Endpoints de téléchargement des rapports comptables PDF SYSCOHADA révisé.
|
||||
*/
|
||||
@Path("/api/comptabilite/pdf")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@RolesAllowed({"ADMIN_ORGANISATION", "TRESORIER", "COMMISSAIRE_COMPTES", "SUPER_ADMIN"})
|
||||
@Tag(name = "Comptabilité PDF", description = "Rapports comptables SYSCOHADA : balance, compte de résultat, grand livre")
|
||||
public class ComptabilitePdfResource {
|
||||
|
||||
@Inject
|
||||
ComptabilitePdfService comptabilitePdfService;
|
||||
|
||||
@GET
|
||||
@Path("/organisations/{organisationId}/balance")
|
||||
@Produces("application/pdf")
|
||||
@Operation(summary = "Balance générale SYSCOHADA",
|
||||
description = "Génère la balance générale (cumul débit/crédit/solde) pour la période.")
|
||||
public Response balance(
|
||||
@PathParam("organisationId") UUID organisationId,
|
||||
@QueryParam("dateDebut") @DefaultValue("") String dateDebutStr,
|
||||
@QueryParam("dateFin") @DefaultValue("") String dateFinStr) {
|
||||
|
||||
LocalDate dateDebut = parseDateOrStartOfYear(dateDebutStr);
|
||||
LocalDate dateFin = parseDateOrToday(dateFinStr);
|
||||
|
||||
byte[] pdf = comptabilitePdfService.genererBalance(organisationId, dateDebut, dateFin);
|
||||
return buildPdfResponse(pdf, "balance_" + organisationId + ".pdf");
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/organisations/{organisationId}/compte-de-resultat")
|
||||
@Produces("application/pdf")
|
||||
@Operation(summary = "Compte de résultat SYSCOHADA",
|
||||
description = "Génère le compte de résultat (produits classes 7/8 − charges classes 6/8).")
|
||||
public Response compteDeResultat(
|
||||
@PathParam("organisationId") UUID organisationId,
|
||||
@QueryParam("dateDebut") @DefaultValue("") String dateDebutStr,
|
||||
@QueryParam("dateFin") @DefaultValue("") String dateFinStr) {
|
||||
|
||||
LocalDate dateDebut = parseDateOrStartOfYear(dateDebutStr);
|
||||
LocalDate dateFin = parseDateOrToday(dateFinStr);
|
||||
|
||||
byte[] pdf = comptabilitePdfService.genererCompteResultat(organisationId, dateDebut, dateFin);
|
||||
return buildPdfResponse(pdf, "compte_resultat_" + organisationId + ".pdf");
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/organisations/{organisationId}/grand-livre/{numeroCompte}")
|
||||
@Produces("application/pdf")
|
||||
@Operation(summary = "Grand livre d'un compte SYSCOHADA",
|
||||
description = "Génère le grand livre (détail chronologique) pour un compte comptable donné.")
|
||||
public Response grandLivre(
|
||||
@PathParam("organisationId") UUID organisationId,
|
||||
@PathParam("numeroCompte") String numeroCompte,
|
||||
@QueryParam("dateDebut") @DefaultValue("") String dateDebutStr,
|
||||
@QueryParam("dateFin") @DefaultValue("") String dateFinStr) {
|
||||
|
||||
LocalDate dateDebut = parseDateOrStartOfYear(dateDebutStr);
|
||||
LocalDate dateFin = parseDateOrToday(dateFinStr);
|
||||
|
||||
byte[] pdf = comptabilitePdfService.genererGrandLivre(organisationId, numeroCompte, dateDebut, dateFin);
|
||||
return buildPdfResponse(pdf, "grand_livre_" + numeroCompte + ".pdf");
|
||||
}
|
||||
|
||||
private static Response buildPdfResponse(byte[] pdf, String filename) {
|
||||
return Response.ok(pdf)
|
||||
.header("Content-Disposition", "attachment; filename=\"" + filename + "\"")
|
||||
.header("Content-Length", pdf.length)
|
||||
.build();
|
||||
}
|
||||
|
||||
private static LocalDate parseDateOrStartOfYear(String s) {
|
||||
if (s == null || s.isBlank()) return LocalDate.of(LocalDate.now().getYear(), 1, 1);
|
||||
try { return LocalDate.parse(s); } catch (Exception e) {
|
||||
throw new BadRequestException("Format de date invalide (attendu : YYYY-MM-DD) : " + s);
|
||||
}
|
||||
}
|
||||
|
||||
private static LocalDate parseDateOrToday(String s) {
|
||||
if (s == null || s.isBlank()) return LocalDate.now();
|
||||
try { return LocalDate.parse(s); } catch (Exception e) {
|
||||
throw new BadRequestException("Format de date invalide (attendu : YYYY-MM-DD) : " + s);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package dev.lions.unionflow.server.resource;
|
||||
|
||||
import dev.lions.unionflow.server.api.dto.kyc.KycDossierRequest;
|
||||
import dev.lions.unionflow.server.api.dto.kyc.KycDossierResponse;
|
||||
import dev.lions.unionflow.server.service.KycAmlService;
|
||||
import io.quarkus.security.identity.SecurityIdentity;
|
||||
import jakarta.annotation.security.RolesAllowed;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.ws.rs.*;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Endpoints KYC/AML — gestion des dossiers d'identification et évaluation risque LCB-FT.
|
||||
*/
|
||||
@Path("/api/kyc")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
public class KycResource {
|
||||
|
||||
@Inject
|
||||
KycAmlService kycAmlService;
|
||||
|
||||
@Inject
|
||||
SecurityIdentity identity;
|
||||
|
||||
/** Soumet ou met à jour un dossier KYC pour un membre. */
|
||||
@POST
|
||||
@Path("/dossiers")
|
||||
@RolesAllowed({"ADMIN_ORGANISATION", "TRESORIER", "SUPER_ADMIN"})
|
||||
public Response soumettre(@Valid KycDossierRequest request) {
|
||||
KycDossierResponse response = kycAmlService.soumettreOuMettreAJour(request, identity.getPrincipal().getName());
|
||||
return Response.status(Response.Status.CREATED).entity(response).build();
|
||||
}
|
||||
|
||||
/** Récupère le dossier KYC actif d'un membre. */
|
||||
@GET
|
||||
@Path("/membres/{membreId}")
|
||||
@RolesAllowed({"ADMIN_ORGANISATION", "TRESORIER", "SUPER_ADMIN"})
|
||||
public Response getDossierActif(@PathParam("membreId") UUID membreId) {
|
||||
return kycAmlService.getDossierActif(membreId)
|
||||
.map(d -> Response.ok(d).build())
|
||||
.orElse(Response.status(Response.Status.NOT_FOUND)
|
||||
.entity(Map.of("error", "Aucun dossier KYC actif pour ce membre."))
|
||||
.build());
|
||||
}
|
||||
|
||||
/** Évalue le score de risque LCB-FT du membre. */
|
||||
@POST
|
||||
@Path("/membres/{membreId}/evaluer-risque")
|
||||
@RolesAllowed({"ADMIN_ORGANISATION", "TRESORIER", "SUPER_ADMIN"})
|
||||
public Response evaluerRisque(@PathParam("membreId") UUID membreId) {
|
||||
KycDossierResponse response = kycAmlService.evaluerRisque(membreId);
|
||||
return Response.ok(response).build();
|
||||
}
|
||||
|
||||
/** Valide manuellement un dossier KYC (agent habilité). */
|
||||
@POST
|
||||
@Path("/dossiers/{dossierId}/valider")
|
||||
@RolesAllowed({"SUPER_ADMIN", "ADMIN_ORGANISATION"})
|
||||
public Response valider(
|
||||
@PathParam("dossierId") UUID dossierId,
|
||||
@QueryParam("validateurId") UUID validateurId,
|
||||
@QueryParam("notes") String notes) {
|
||||
KycDossierResponse response = kycAmlService.valider(
|
||||
dossierId, validateurId, notes, identity.getPrincipal().getName());
|
||||
return Response.ok(response).build();
|
||||
}
|
||||
|
||||
/** Refuse un dossier KYC avec motif. */
|
||||
@POST
|
||||
@Path("/dossiers/{dossierId}/refuser")
|
||||
@RolesAllowed({"SUPER_ADMIN", "ADMIN_ORGANISATION"})
|
||||
public Response refuser(
|
||||
@PathParam("dossierId") UUID dossierId,
|
||||
@QueryParam("validateurId") UUID validateurId,
|
||||
@QueryParam("motif") String motif) {
|
||||
KycDossierResponse response = kycAmlService.refuser(
|
||||
dossierId, validateurId, motif, identity.getPrincipal().getName());
|
||||
return Response.ok(response).build();
|
||||
}
|
||||
|
||||
/** Liste les dossiers KYC en attente de validation. */
|
||||
@GET
|
||||
@Path("/dossiers/en-attente")
|
||||
@RolesAllowed({"SUPER_ADMIN", "ADMIN_ORGANISATION"})
|
||||
public List<KycDossierResponse> getDossiersEnAttente() {
|
||||
return kycAmlService.getDossiersEnAttente();
|
||||
}
|
||||
|
||||
/** Liste les membres PEP (Personnes Exposées Politiquement). */
|
||||
@GET
|
||||
@Path("/pep")
|
||||
@RolesAllowed({"SUPER_ADMIN"})
|
||||
public List<KycDossierResponse> getPep() {
|
||||
return kycAmlService.getDossiersPep();
|
||||
}
|
||||
|
||||
/** Pièces d'identité expirant dans les 30 jours. */
|
||||
@GET
|
||||
@Path("/pieces-expirant-bientot")
|
||||
@RolesAllowed({"SUPER_ADMIN", "ADMIN_ORGANISATION", "TRESORIER"})
|
||||
public List<KycDossierResponse> getPiecesExpirant() {
|
||||
return kycAmlService.getPiecesExpirantDansLes30Jours();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package dev.lions.unionflow.server.resource;
|
||||
|
||||
import dev.lions.unionflow.server.api.payment.*;
|
||||
import dev.lions.unionflow.server.entity.SouscriptionOrganisation;
|
||||
import dev.lions.unionflow.server.payment.orchestration.PaymentOrchestrator;
|
||||
import dev.lions.unionflow.server.payment.orchestration.PaymentProviderRegistry;
|
||||
import dev.lions.unionflow.server.repository.SouscriptionOrganisationRepository;
|
||||
import jakarta.annotation.security.PermitAll;
|
||||
import jakarta.annotation.security.RolesAllowed;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.ws.rs.*;
|
||||
import jakarta.ws.rs.core.Context;
|
||||
import jakarta.ws.rs.core.HttpHeaders;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Endpoints de paiement unifiés — abstraction multi-provider.
|
||||
* Remplace à terme les endpoints Wave-spécifiques.
|
||||
*/
|
||||
@Slf4j
|
||||
@Path("/api/paiements")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
public class PaiementUnifieResource {
|
||||
|
||||
@Inject
|
||||
PaymentOrchestrator orchestrator;
|
||||
|
||||
@Inject
|
||||
PaymentProviderRegistry registry;
|
||||
|
||||
@Inject
|
||||
SouscriptionOrganisationRepository souscriptionRepository;
|
||||
|
||||
/**
|
||||
* Initie un paiement via le provider demandé (ou le provider par défaut).
|
||||
*
|
||||
* <p>Exemple : {@code POST /api/paiements/initier?provider=WAVE}
|
||||
*/
|
||||
@POST
|
||||
@Path("/initier")
|
||||
@RolesAllowed({"MEMBRE_ACTIF", "ADMIN_ORGANISATION", "TRESORIER", "SUPER_ADMIN"})
|
||||
public Response initier(
|
||||
@QueryParam("provider") String provider,
|
||||
PaiementInitierRequest req) {
|
||||
try {
|
||||
// Si une souscription est fournie, utiliser le providerDefaut de sa formule
|
||||
String resolvedProvider = provider;
|
||||
if (req.souscriptionId() != null) {
|
||||
resolvedProvider = souscriptionRepository.findByIdOptional(req.souscriptionId())
|
||||
.map(SouscriptionOrganisation::getFormule)
|
||||
.map(f -> f.getProviderDefaut())
|
||||
.filter(p -> p != null && !p.isBlank())
|
||||
.orElse(provider);
|
||||
}
|
||||
|
||||
CheckoutRequest checkoutRequest = new CheckoutRequest(
|
||||
req.montant(),
|
||||
req.devise() != null ? req.devise() : "XOF",
|
||||
req.telephone(),
|
||||
req.email(),
|
||||
req.reference(),
|
||||
req.successUrl(),
|
||||
req.cancelUrl(),
|
||||
Map.of()
|
||||
);
|
||||
CheckoutSession session = orchestrator.initierPaiement(checkoutRequest, resolvedProvider);
|
||||
return Response.ok(session).build();
|
||||
} catch (PaymentException e) {
|
||||
return Response.status(e.getHttpStatus())
|
||||
.entity(Map.of("error", e.getMessage(), "provider", e.getProviderCode()))
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Webhook entrant d'un provider. Vérifie la signature et met à jour le statut.
|
||||
* Route : {@code POST /api/paiements/webhook/{provider}}
|
||||
*/
|
||||
@POST
|
||||
@Path("/webhook/{provider}")
|
||||
@PermitAll
|
||||
@Consumes(MediaType.WILDCARD)
|
||||
public Response webhook(
|
||||
@PathParam("provider") String providerCode,
|
||||
String rawBody,
|
||||
@Context HttpHeaders httpHeaders) {
|
||||
try {
|
||||
PaymentProvider provider = registry.get(providerCode.toUpperCase());
|
||||
Map<String, String> headers = httpHeaders.getRequestHeaders().entrySet().stream()
|
||||
.collect(Collectors.toMap(
|
||||
Map.Entry::getKey,
|
||||
e -> e.getValue().isEmpty() ? "" : e.getValue().get(0)
|
||||
));
|
||||
|
||||
PaymentEvent event = provider.processWebhook(rawBody, headers);
|
||||
orchestrator.handleEvent(event);
|
||||
return Response.ok().build();
|
||||
|
||||
} catch (UnsupportedOperationException e) {
|
||||
return Response.status(Response.Status.NOT_FOUND)
|
||||
.entity(Map.of("error", "Provider inconnu : " + providerCode))
|
||||
.build();
|
||||
} catch (PaymentException e) {
|
||||
log.error("Webhook {} rejeté : {}", providerCode, e.getMessage());
|
||||
return Response.status(e.getHttpStatus())
|
||||
.entity(Map.of("error", e.getMessage()))
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
/** Retourne les providers de paiement disponibles. */
|
||||
@GET
|
||||
@Path("/providers")
|
||||
@RolesAllowed({"ADMIN_ORGANISATION", "SUPER_ADMIN"})
|
||||
public List<String> getProviders() {
|
||||
return registry.getAvailableCodes();
|
||||
}
|
||||
|
||||
public record PaiementInitierRequest(
|
||||
BigDecimal montant,
|
||||
String devise,
|
||||
String telephone,
|
||||
String email,
|
||||
String reference,
|
||||
String successUrl,
|
||||
String cancelUrl,
|
||||
/** Optionnel — si fourni, le providerDefaut de la formule prend le dessus sur le query param. */
|
||||
UUID souscriptionId
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package dev.lions.unionflow.server.resource.mutuelle;
|
||||
|
||||
import dev.lions.unionflow.server.api.dto.mutuelle.financier.ParametresFinanciersMutuellRequest;
|
||||
import dev.lions.unionflow.server.api.dto.mutuelle.financier.ParametresFinanciersMutuellResponse;
|
||||
import dev.lions.unionflow.server.security.RequiresModule;
|
||||
import dev.lions.unionflow.server.service.mutuelle.InteretsEpargneService;
|
||||
import dev.lions.unionflow.server.service.mutuelle.ParametresFinanciersService;
|
||||
import jakarta.annotation.security.RolesAllowed;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.ws.rs.*;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
@Path("/api/v1/mutuelle/parametres-financiers")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@RequiresModule("EPARGNE")
|
||||
public class ParametresFinanciersResource {
|
||||
|
||||
@Inject ParametresFinanciersService parametresService;
|
||||
@Inject InteretsEpargneService interetsService;
|
||||
|
||||
@GET
|
||||
@Path("/{orgId}")
|
||||
@RolesAllowed({"ADMIN", "SUPER_ADMIN", "ADMIN_ORGANISATION", "MUTUELLE_RESP"})
|
||||
public Response getByOrganisation(@PathParam("orgId") UUID orgId) {
|
||||
return Response.ok(parametresService.getByOrganisation(orgId)).build();
|
||||
}
|
||||
|
||||
@POST
|
||||
@RolesAllowed({"ADMIN", "SUPER_ADMIN", "ADMIN_ORGANISATION"})
|
||||
public Response creerOuMettrAJour(@Valid ParametresFinanciersMutuellRequest request) {
|
||||
ParametresFinanciersMutuellResponse resp = parametresService.creerOuMettrAJour(request);
|
||||
return Response.ok(resp).build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Déclenche manuellement le calcul des intérêts / dividendes pour une organisation.
|
||||
* Utile pour régularisation ou test.
|
||||
*/
|
||||
@POST
|
||||
@Path("/{orgId}/calculer-interets")
|
||||
@RolesAllowed({"ADMIN", "SUPER_ADMIN", "ADMIN_ORGANISATION"})
|
||||
public Response calculerInterets(@PathParam("orgId") UUID orgId) {
|
||||
Map<String, Object> result = interetsService.calculerManuellement(orgId);
|
||||
return Response.ok(result).build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package dev.lions.unionflow.server.resource.mutuelle;
|
||||
|
||||
import dev.lions.unionflow.server.security.RequiresModule;
|
||||
import dev.lions.unionflow.server.service.mutuelle.ReleveComptePdfService;
|
||||
import jakarta.annotation.security.RolesAllowed;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.ws.rs.*;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import jakarta.ws.rs.core.Response.ResponseBuilder;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Relevés de compte en PDF.
|
||||
* - GET /api/v1/releves/epargne/{compteId} → relevé épargne
|
||||
* - GET /api/v1/releves/parts-sociales/{compteId} → relevé parts sociales
|
||||
*/
|
||||
@Path("/api/v1/releves")
|
||||
@RequiresModule("EPARGNE")
|
||||
public class ReleveCompteResource {
|
||||
|
||||
@Inject ReleveComptePdfService releveService;
|
||||
|
||||
@GET
|
||||
@Path("/epargne/{compteId}")
|
||||
@Produces("application/pdf")
|
||||
@RolesAllowed({"ADMIN", "SUPER_ADMIN", "ADMIN_ORGANISATION", "MUTUELLE_RESP", "MEMBRE", "USER"})
|
||||
public Response releveEpargne(
|
||||
@PathParam("compteId") UUID compteId,
|
||||
@QueryParam("dateDebut") String dateDebutStr,
|
||||
@QueryParam("dateFin") String dateFinStr) {
|
||||
|
||||
LocalDate dateDebut = parseDate(dateDebutStr);
|
||||
LocalDate dateFin = parseDate(dateFinStr);
|
||||
byte[] pdf = releveService.genererReleveEpargne(compteId, dateDebut, dateFin);
|
||||
|
||||
ResponseBuilder rb = Response.ok(pdf);
|
||||
rb.header("Content-Disposition",
|
||||
"attachment; filename=\"releve-epargne-" + compteId + ".pdf\"");
|
||||
rb.header("Content-Type", MediaType.valueOf("application/pdf"));
|
||||
return rb.build();
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/parts-sociales/{compteId}")
|
||||
@Produces("application/pdf")
|
||||
@RolesAllowed({"ADMIN", "SUPER_ADMIN", "ADMIN_ORGANISATION", "MUTUELLE_RESP", "MEMBRE", "USER"})
|
||||
public Response releveParts(
|
||||
@PathParam("compteId") UUID compteId,
|
||||
@QueryParam("dateDebut") String dateDebutStr,
|
||||
@QueryParam("dateFin") String dateFinStr) {
|
||||
|
||||
LocalDate dateDebut = parseDate(dateDebutStr);
|
||||
LocalDate dateFin = parseDate(dateFinStr);
|
||||
byte[] pdf = releveService.genererReleveParts(compteId, dateDebut, dateFin);
|
||||
|
||||
ResponseBuilder rb = Response.ok(pdf);
|
||||
rb.header("Content-Disposition",
|
||||
"attachment; filename=\"releve-parts-" + compteId + ".pdf\"");
|
||||
rb.header("Content-Type", MediaType.valueOf("application/pdf"));
|
||||
return rb.build();
|
||||
}
|
||||
|
||||
private LocalDate parseDate(String s) {
|
||||
if (s == null || s.isBlank()) return null;
|
||||
try {
|
||||
return LocalDate.parse(s);
|
||||
} catch (Exception e) {
|
||||
throw new IllegalArgumentException("Format de date invalide. Utilisez YYYY-MM-DD. Valeur reçue: " + s);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package dev.lions.unionflow.server.resource.mutuelle.parts;
|
||||
|
||||
import dev.lions.unionflow.server.api.dto.mutuelle.parts.ComptePartsSocialesRequest;
|
||||
import dev.lions.unionflow.server.api.dto.mutuelle.parts.ComptePartsSocialesResponse;
|
||||
import dev.lions.unionflow.server.api.dto.mutuelle.parts.TransactionPartsSocialesRequest;
|
||||
import dev.lions.unionflow.server.api.dto.mutuelle.parts.TransactionPartsSocialesResponse;
|
||||
import dev.lions.unionflow.server.security.RequiresModule;
|
||||
import dev.lions.unionflow.server.service.mutuelle.parts.ComptePartsSocialesService;
|
||||
import jakarta.annotation.security.RolesAllowed;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.ws.rs.*;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@Path("/api/v1/parts-sociales/comptes")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@RequiresModule("EPARGNE")
|
||||
public class ComptePartsSocialesResource {
|
||||
|
||||
@Inject
|
||||
ComptePartsSocialesService service;
|
||||
|
||||
@POST
|
||||
@RolesAllowed({"ADMIN", "SUPER_ADMIN", "ADMIN_ORGANISATION", "MUTUELLE_RESP"})
|
||||
public Response ouvrirCompte(@Valid ComptePartsSocialesRequest request) {
|
||||
ComptePartsSocialesResponse resp = service.ouvrirCompte(request);
|
||||
return Response.status(Response.Status.CREATED).entity(resp).build();
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("/transactions")
|
||||
@RolesAllowed({"ADMIN", "SUPER_ADMIN", "ADMIN_ORGANISATION", "MUTUELLE_RESP"})
|
||||
public Response enregistrerTransaction(@Valid TransactionPartsSocialesRequest request) {
|
||||
TransactionPartsSocialesResponse resp = service.enregistrerSouscription(request);
|
||||
return Response.status(Response.Status.CREATED).entity(resp).build();
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/{id}")
|
||||
@RolesAllowed({"ADMIN", "SUPER_ADMIN", "ADMIN_ORGANISATION", "MUTUELLE_RESP", "MEMBRE", "USER"})
|
||||
public Response getById(@PathParam("id") UUID id) {
|
||||
return Response.ok(service.getById(id)).build();
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/membre/{membreId}")
|
||||
@RolesAllowed({"ADMIN", "SUPER_ADMIN", "ADMIN_ORGANISATION", "MUTUELLE_RESP", "MEMBRE", "USER"})
|
||||
public Response getByMembre(@PathParam("membreId") UUID membreId) {
|
||||
List<ComptePartsSocialesResponse> list = service.getByMembre(membreId);
|
||||
return Response.ok(list).build();
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/organisation/{orgId}")
|
||||
@RolesAllowed({"ADMIN", "SUPER_ADMIN", "ADMIN_ORGANISATION", "MUTUELLE_RESP"})
|
||||
public Response getByOrganisation(@PathParam("orgId") UUID orgId) {
|
||||
List<ComptePartsSocialesResponse> list = service.getByOrganisation(orgId);
|
||||
return Response.ok(list).build();
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/{id}/transactions")
|
||||
@RolesAllowed({"ADMIN", "SUPER_ADMIN", "ADMIN_ORGANISATION", "MUTUELLE_RESP", "MEMBRE", "USER"})
|
||||
public Response getTransactions(@PathParam("id") UUID id) {
|
||||
List<TransactionPartsSocialesResponse> list = service.getTransactions(id);
|
||||
return Response.ok(list).build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package dev.lions.unionflow.server.security;
|
||||
|
||||
import dev.lions.unionflow.server.entity.Organisation;
|
||||
import dev.lions.unionflow.server.repository.OrganisationRepository;
|
||||
import io.smallrye.jwt.auth.principal.JWTParser;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.ws.rs.BadRequestException;
|
||||
import jakarta.ws.rs.ForbiddenException;
|
||||
import org.eclipse.microprofile.jwt.JsonWebToken;
|
||||
import org.jboss.logging.Logger;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Résout l'organisation active depuis le claim {@code organization} du JWT Keycloak 26.
|
||||
*
|
||||
* <p>Keycloak 26 Organizations injecte dans le token un claim de la forme :
|
||||
* <pre>
|
||||
* "organization": {
|
||||
* "mutuelle-gbane": { "id": "uuid-kc-org", "name": "Mutuelle GBANE", "alias": "mutuelle-gbane" }
|
||||
* }
|
||||
* </pre>
|
||||
*
|
||||
* <p>Ce bean remplace progressivement {@link OrganisationContextFilter} (header-based).
|
||||
* Pendant la période de transition, le filtre header reste actif — ce resolver est
|
||||
* utilisé en complément par les endpoints qui lisent explicitement le claim JWT.
|
||||
*
|
||||
* <p>Un token scopé à une seule organization → résolution directe.
|
||||
* Un token multi-org sans scoping → exception (le client doit re-authentifier avec scoping).
|
||||
*/
|
||||
@ApplicationScoped
|
||||
public class OrganisationContextResolver {
|
||||
|
||||
private static final Logger LOG = Logger.getLogger(OrganisationContextResolver.class);
|
||||
|
||||
@Inject
|
||||
JsonWebToken jwt;
|
||||
|
||||
@Inject
|
||||
OrganisationRepository organisationRepository;
|
||||
|
||||
/**
|
||||
* Résout l'UUID UnionFlow de l'organisation active depuis le claim JWT {@code organization}.
|
||||
*
|
||||
* @throws BadRequestException si le token est multi-org sans scoping ou si le claim manque
|
||||
* @throws ForbiddenException si aucune organisation UnionFlow ne correspond au keycloak_org_id
|
||||
*/
|
||||
public UUID resolveOrganisationId() {
|
||||
var orgClaim = jwt.<java.util.Map<String, Object>>getClaim("organization");
|
||||
|
||||
if (orgClaim == null || orgClaim.isEmpty()) {
|
||||
throw new BadRequestException(
|
||||
"Token JWT sans claim 'organization' — connectez-vous dans le contexte d'une organisation.");
|
||||
}
|
||||
|
||||
if (orgClaim.size() > 1) {
|
||||
throw new BadRequestException(
|
||||
"Token multi-organisation non scopé. Ré-authentifiez-vous avec l'organisation cible.");
|
||||
}
|
||||
|
||||
// Single-org token : prendre la première (et seule) entrée
|
||||
var entry = orgClaim.entrySet().iterator().next().getValue();
|
||||
String kcOrgIdStr = extractId(entry);
|
||||
|
||||
if (kcOrgIdStr == null) {
|
||||
LOG.warnf("Claim organization sans champ 'id' : %s", entry);
|
||||
throw new BadRequestException("Claim 'organization' malformé — champ 'id' manquant.");
|
||||
}
|
||||
|
||||
UUID kcOrgId;
|
||||
try {
|
||||
kcOrgId = UUID.fromString(kcOrgIdStr);
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new BadRequestException("Claim organization.id n'est pas un UUID valide : " + kcOrgIdStr);
|
||||
}
|
||||
|
||||
Optional<Organisation> orgOpt = organisationRepository
|
||||
.find("keycloakOrgId = ?1 AND actif = true", kcOrgId)
|
||||
.firstResultOptional();
|
||||
|
||||
if (orgOpt.isEmpty()) {
|
||||
LOG.warnf("Aucune organisation UnionFlow avec keycloak_org_id=%s", kcOrgId);
|
||||
throw new ForbiddenException(
|
||||
"Aucune organisation active trouvée pour cet identifiant Keycloak Organization.");
|
||||
}
|
||||
|
||||
return orgOpt.get().getId();
|
||||
}
|
||||
|
||||
/**
|
||||
* Variante qui retourne un {@code Optional} vide si le claim est absent
|
||||
* (pour les endpoints compatibles avec les deux modes header + JWT).
|
||||
*/
|
||||
public Optional<UUID> resolveOrganisationIdIfPresent() {
|
||||
try {
|
||||
var orgClaim = jwt.<java.util.Map<String, Object>>getClaim("organization");
|
||||
if (orgClaim == null || orgClaim.isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return Optional.of(resolveOrganisationId());
|
||||
} catch (BadRequestException | ForbiddenException e) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private String extractId(Object entry) {
|
||||
if (entry instanceof java.util.Map) {
|
||||
Object id = ((java.util.Map<String, Object>) entry).get("id");
|
||||
return id != null ? id.toString() : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package dev.lions.unionflow.server.security;
|
||||
|
||||
import io.quarkus.security.identity.SecurityIdentity;
|
||||
import jakarta.annotation.Priority;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.ws.rs.Priorities;
|
||||
import jakarta.ws.rs.container.ContainerRequestContext;
|
||||
import jakarta.ws.rs.container.ContainerRequestFilter;
|
||||
import jakarta.ws.rs.ext.Provider;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
import java.io.IOException;
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Filtre JAX-RS qui positionne les variables de session PostgreSQL pour le RLS.
|
||||
*
|
||||
* <p>Doit s'exécuter APRÈS {@link OrganisationContextFilter} (priorité AUTHORIZATION + 20).
|
||||
*
|
||||
* <p>Variables positionnées :
|
||||
* <ul>
|
||||
* <li>{@code app.current_org_id} : UUID de l'organisation active (null → "00000000-0000-0000-0000-000000000000")</li>
|
||||
* <li>{@code app.is_super_admin} : 'true' si SUPER_ADMIN (bypass RLS pour requêtes cross-tenant)</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p><strong>Limitation connue</strong> : ce filtre ouvre une connexion séparée du pool Agroal.
|
||||
* {@code SET LOCAL} affecte CETTE connexion, pas celle utilisée par Hibernate pour les queries.
|
||||
* Pour une isolation réelle, il faut brancher le {@code SET} sur le même contexte transactionnel
|
||||
* Hibernate — via {@code CurrentTenantIdentifierResolver} + {@code MultiTenantConnectionProvider},
|
||||
* ou via un {@code TransactionSynchronization} qui s'exécute dans la même transaction JTA.
|
||||
* Ce filtre est un draft de préparation prod ; l'intégration complète est prévue en P2.4.
|
||||
*
|
||||
* <p>En dev, RLS est désactivé de fait car le user {@code skyfile} est owner
|
||||
* et bypasse naturellement les policies. Ce filter est actif pour la préparation prod.
|
||||
*/
|
||||
@Slf4j
|
||||
@Provider
|
||||
@Priority(Priorities.AUTHORIZATION + 20)
|
||||
public class RlsConnectionInitializer implements ContainerRequestFilter {
|
||||
|
||||
private static final String NULL_ORG_ID = "00000000-0000-0000-0000-000000000000";
|
||||
|
||||
@Inject
|
||||
OrganisationContextHolder contextHolder;
|
||||
|
||||
@Inject
|
||||
SecurityIdentity identity;
|
||||
|
||||
@Inject
|
||||
DataSource dataSource;
|
||||
|
||||
@Override
|
||||
public void filter(ContainerRequestContext requestContext) throws IOException {
|
||||
if (identity == null || identity.isAnonymous()) return;
|
||||
|
||||
boolean isSuperAdmin = identity.getRoles() != null
|
||||
&& (identity.getRoles().contains("SUPER_ADMIN")
|
||||
|| identity.getRoles().contains("SUPERADMIN"));
|
||||
|
||||
UUID orgId = contextHolder.hasContext() ? contextHolder.getOrganisationId() : null;
|
||||
String orgIdStr = orgId != null ? orgId.toString() : NULL_ORG_ID;
|
||||
|
||||
try (Connection conn = dataSource.getConnection()) {
|
||||
try (PreparedStatement stmt = conn.prepareStatement(
|
||||
"SET LOCAL app.current_org_id = '" + orgIdStr + "'; "
|
||||
+ "SET LOCAL app.is_super_admin = '" + isSuperAdmin + "'")) {
|
||||
stmt.execute();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// Non bloquant en dev (user owner bypasse RLS)
|
||||
log.debug("RLS session variables non positionnées (ignoré en dev) : {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package dev.lions.unionflow.server.security;
|
||||
|
||||
import io.quarkus.security.identity.SecurityIdentity;
|
||||
import jakarta.annotation.Priority;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.interceptor.AroundInvoke;
|
||||
import jakarta.interceptor.Interceptor;
|
||||
import jakarta.interceptor.InvocationContext;
|
||||
import jakarta.persistence.EntityManager;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Intercepteur CDI qui positionne les variables de session PostgreSQL pour le RLS
|
||||
* DANS la même connexion JTA que Hibernate.
|
||||
*
|
||||
* <p>Priorité 300 : s'exécute APRÈS l'intercepteur {@code @Transactional} (priorité ~200)
|
||||
* mais AVANT le code métier, garantissant que {@code SET LOCAL} affecte la connexion
|
||||
* JTA active.
|
||||
*
|
||||
* <p>Utilise {@code set_config(name, value, true)} (is_local=true) qui est l'équivalent
|
||||
* de {@code SET LOCAL} et s'annule automatiquement en fin de transaction.
|
||||
*
|
||||
* <p>Si aucun contexte d'organisation n'est disponible (SUPER_ADMIN sans org, ou endpoint
|
||||
* public), positionne l'UUID nul pour que les policies RLS utilisent le fallback.
|
||||
*/
|
||||
@Slf4j
|
||||
@Interceptor
|
||||
@RlsEnabled
|
||||
@Priority(300)
|
||||
public class RlsContextInterceptor {
|
||||
|
||||
private static final String NULL_ORG_UUID = "00000000-0000-0000-0000-000000000000";
|
||||
|
||||
@Inject
|
||||
EntityManager em;
|
||||
|
||||
@Inject
|
||||
OrganisationContextHolder contextHolder;
|
||||
|
||||
@Inject
|
||||
SecurityIdentity identity;
|
||||
|
||||
@AroundInvoke
|
||||
Object applyRlsContext(InvocationContext ctx) throws Exception {
|
||||
if (identity == null || identity.isAnonymous()) {
|
||||
return ctx.proceed();
|
||||
}
|
||||
|
||||
boolean isSuperAdmin = identity.getRoles() != null
|
||||
&& (identity.getRoles().contains("SUPER_ADMIN")
|
||||
|| identity.getRoles().contains("SUPERADMIN"));
|
||||
|
||||
UUID orgId = contextHolder.hasContext() ? contextHolder.getOrganisationId() : null;
|
||||
String orgIdStr = orgId != null ? orgId.toString() : NULL_ORG_UUID;
|
||||
|
||||
try {
|
||||
em.createNativeQuery(
|
||||
"SELECT set_config('app.current_org_id', :orgId, true), "
|
||||
+ "set_config('app.is_super_admin', :isSuperAdmin, true)")
|
||||
.setParameter("orgId", orgIdStr)
|
||||
.setParameter("isSuperAdmin", String.valueOf(isSuperAdmin))
|
||||
.getSingleResult();
|
||||
log.debug("RLS context positionné : org={}, superAdmin={}", orgIdStr, isSuperAdmin);
|
||||
} catch (Exception e) {
|
||||
// Non bloquant : en dev, le user owner bypasse naturellement les policies
|
||||
log.debug("RLS set_config ignoré (probablement hors transaction) : {}", e.getMessage());
|
||||
}
|
||||
|
||||
return ctx.proceed();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package dev.lions.unionflow.server.security;
|
||||
|
||||
import jakarta.interceptor.InterceptorBinding;
|
||||
import java.lang.annotation.*;
|
||||
|
||||
/**
|
||||
* Marque une méthode ou classe transactionnelle pour que le filtre RLS
|
||||
* positionne les variables de session PostgreSQL ({@code app.current_org_id},
|
||||
* {@code app.is_super_admin}) dans la même connexion JTA que Hibernate.
|
||||
*
|
||||
* <p>Doit toujours être combiné avec {@code @Transactional} (ou être dans une
|
||||
* méthode appelée depuis un contexte transactionnel existant).
|
||||
*
|
||||
* <p>Usage :
|
||||
* <pre>{@code
|
||||
* @RlsEnabled
|
||||
* @Transactional
|
||||
* public List<Cotisation> findAll() { ... }
|
||||
* }</pre>
|
||||
*/
|
||||
@Inherited
|
||||
@InterceptorBinding
|
||||
@Target({ElementType.TYPE, ElementType.METHOD})
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
public @interface RlsEnabled {
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
package dev.lions.unionflow.server.service;
|
||||
|
||||
import com.lowagie.text.*;
|
||||
import com.lowagie.text.Font;
|
||||
import com.lowagie.text.pdf.*;
|
||||
import dev.lions.unionflow.server.api.enums.comptabilite.TypeCompteComptable;
|
||||
import dev.lions.unionflow.server.entity.CompteComptable;
|
||||
import dev.lions.unionflow.server.entity.EcritureComptable;
|
||||
import dev.lions.unionflow.server.entity.LigneEcriture;
|
||||
import dev.lions.unionflow.server.entity.Organisation;
|
||||
import dev.lions.unionflow.server.repository.CompteComptableRepository;
|
||||
import dev.lions.unionflow.server.repository.EcritureComptableRepository;
|
||||
import dev.lions.unionflow.server.repository.OrganisationRepository;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.ws.rs.NotFoundException;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.awt.Color;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Génération des rapports comptables PDF SYSCOHADA révisé.
|
||||
*
|
||||
* <p>Rapports disponibles :
|
||||
* <ul>
|
||||
* <li>Grand livre : détail de toutes les écritures par compte</li>
|
||||
* <li>Balance générale : soldes débit/crédit/solde net par compte</li>
|
||||
* <li>Compte de résultat : produits (classe 7+8) - charges (classe 6+8)</li>
|
||||
* </ul>
|
||||
*/
|
||||
@Slf4j
|
||||
@ApplicationScoped
|
||||
public class ComptabilitePdfService {
|
||||
|
||||
private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("dd/MM/yyyy");
|
||||
private static final Color COLOR_HEADER = new Color(0x1A, 0x56, 0x8C);
|
||||
private static final Color COLOR_HEADER_TEXT = Color.WHITE;
|
||||
private static final Color COLOR_TOTAL_ROW = new Color(0xE8, 0xF0, 0xFE);
|
||||
private static final Color COLOR_ROW_ALT = new Color(0xF8, 0xFA, 0xFF);
|
||||
|
||||
@Inject
|
||||
OrganisationRepository organisationRepository;
|
||||
|
||||
@Inject
|
||||
CompteComptableRepository compteComptableRepository;
|
||||
|
||||
@Inject
|
||||
EcritureComptableRepository ecritureComptableRepository;
|
||||
|
||||
// ── Balance générale ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Génère la balance générale SYSCOHADA pour une organisation.
|
||||
* Liste tous les comptes avec cumul débit, cumul crédit et solde.
|
||||
*/
|
||||
public byte[] genererBalance(UUID organisationId, LocalDate dateDebut, LocalDate dateFin) {
|
||||
Organisation org = getOrg(organisationId);
|
||||
List<CompteComptable> comptes = compteComptableRepository.findByOrganisation(organisationId);
|
||||
|
||||
Map<String, BigDecimal[]> totauxParCompte = calculerTotauxParCompte(organisationId, dateDebut, dateFin);
|
||||
|
||||
try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
|
||||
Document doc = new Document(PageSize.A4.rotate(), 20, 20, 40, 40);
|
||||
PdfWriter.getInstance(doc, baos);
|
||||
doc.open();
|
||||
|
||||
addTitrePage(doc, "BALANCE GÉNÉRALE", org.getNom(), dateDebut, dateFin);
|
||||
|
||||
PdfPTable table = new PdfPTable(6);
|
||||
table.setWidthPercentage(100);
|
||||
table.setWidths(new float[]{10f, 30f, 8f, 15f, 15f, 15f});
|
||||
|
||||
addHeaderCell(table, "Compte");
|
||||
addHeaderCell(table, "Libellé");
|
||||
addHeaderCell(table, "Classe");
|
||||
addHeaderCell(table, "Cumul Débit");
|
||||
addHeaderCell(table, "Cumul Crédit");
|
||||
addHeaderCell(table, "Solde");
|
||||
|
||||
BigDecimal totalDebit = BigDecimal.ZERO;
|
||||
BigDecimal totalCredit = BigDecimal.ZERO;
|
||||
boolean alt = false;
|
||||
|
||||
for (CompteComptable compte : comptes) {
|
||||
BigDecimal[] totaux = totauxParCompte.getOrDefault(
|
||||
compte.getNumeroCompte(), new BigDecimal[]{BigDecimal.ZERO, BigDecimal.ZERO});
|
||||
BigDecimal debit = totaux[0];
|
||||
BigDecimal credit = totaux[1];
|
||||
BigDecimal solde = debit.subtract(credit);
|
||||
|
||||
if (debit.signum() == 0 && credit.signum() == 0) continue;
|
||||
|
||||
Color bg = alt ? COLOR_ROW_ALT : Color.WHITE;
|
||||
addDataCell(table, compte.getNumeroCompte(), bg);
|
||||
addDataCell(table, compte.getLibelle(), bg);
|
||||
addDataCell(table, String.valueOf(compte.getClasseComptable()), bg);
|
||||
addAmountCell(table, debit, bg);
|
||||
addAmountCell(table, credit, bg);
|
||||
addAmountCell(table, solde, bg);
|
||||
|
||||
totalDebit = totalDebit.add(debit);
|
||||
totalCredit = totalCredit.add(credit);
|
||||
alt = !alt;
|
||||
}
|
||||
|
||||
// Ligne totaux
|
||||
BigDecimal totalSolde = totalDebit.subtract(totalCredit);
|
||||
addTotalCell(table, "TOTAUX");
|
||||
addTotalCell(table, "");
|
||||
addTotalCell(table, "");
|
||||
addAmountCell(table, totalDebit, COLOR_TOTAL_ROW);
|
||||
addAmountCell(table, totalCredit, COLOR_TOTAL_ROW);
|
||||
addAmountCell(table, totalSolde, COLOR_TOTAL_ROW);
|
||||
|
||||
doc.add(table);
|
||||
addFooter(doc);
|
||||
doc.close();
|
||||
return baos.toByteArray();
|
||||
} catch (Exception e) {
|
||||
log.error("Erreur génération balance PDF : {}", e.getMessage(), e);
|
||||
throw new RuntimeException("Erreur génération balance PDF", e);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Compte de résultat ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Génère le compte de résultat SYSCOHADA.
|
||||
* Produits (classes 7 et 8 produits) — Charges (classes 6 et 8 charges).
|
||||
*/
|
||||
public byte[] genererCompteResultat(UUID organisationId, LocalDate dateDebut, LocalDate dateFin) {
|
||||
Organisation org = getOrg(organisationId);
|
||||
Map<String, BigDecimal[]> totaux = calculerTotauxParCompte(organisationId, dateDebut, dateFin);
|
||||
|
||||
List<CompteComptable> comptes = compteComptableRepository.findByOrganisation(organisationId);
|
||||
|
||||
BigDecimal totalProduits = BigDecimal.ZERO;
|
||||
BigDecimal totalCharges = BigDecimal.ZERO;
|
||||
List<Object[]> lignesProduits = new ArrayList<>();
|
||||
List<Object[]> lignesCharges = new ArrayList<>();
|
||||
|
||||
for (CompteComptable compte : comptes) {
|
||||
int classe = compte.getClasseComptable();
|
||||
BigDecimal[] t = totaux.getOrDefault(compte.getNumeroCompte(),
|
||||
new BigDecimal[]{BigDecimal.ZERO, BigDecimal.ZERO});
|
||||
BigDecimal solde = t[1].subtract(t[0]); // crédit - débit pour produits
|
||||
|
||||
if ((classe == 7) || (classe == 8 && TypeCompteComptable.PRODUITS.equals(compte.getTypeCompte()))) {
|
||||
if (solde.signum() != 0) {
|
||||
lignesProduits.add(new Object[]{compte.getNumeroCompte(), compte.getLibelle(), solde});
|
||||
totalProduits = totalProduits.add(solde);
|
||||
}
|
||||
} else if ((classe == 6) || (classe == 8 && TypeCompteComptable.CHARGES.equals(compte.getTypeCompte()))) {
|
||||
BigDecimal soldeCharge = t[0].subtract(t[1]); // débit - crédit pour charges
|
||||
if (soldeCharge.signum() != 0) {
|
||||
lignesCharges.add(new Object[]{compte.getNumeroCompte(), compte.getLibelle(), soldeCharge});
|
||||
totalCharges = totalCharges.add(soldeCharge);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BigDecimal resultat = totalProduits.subtract(totalCharges);
|
||||
boolean benefice = resultat.signum() >= 0;
|
||||
|
||||
try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
|
||||
Document doc = new Document(PageSize.A4, 30, 30, 50, 40);
|
||||
PdfWriter.getInstance(doc, baos);
|
||||
doc.open();
|
||||
|
||||
addTitrePage(doc, "COMPTE DE RÉSULTAT", org.getNom(), dateDebut, dateFin);
|
||||
|
||||
// Section PRODUITS
|
||||
addSectionTitle(doc, "PRODUITS D'EXPLOITATION");
|
||||
PdfPTable tableProduits = creerTableau2Colonnes();
|
||||
for (Object[] ligne : lignesProduits) {
|
||||
addDataCell(tableProduits, ligne[0] + " — " + ligne[1], Color.WHITE);
|
||||
addAmountCell(tableProduits, (BigDecimal) ligne[2], Color.WHITE);
|
||||
}
|
||||
addTotalCell(tableProduits, "TOTAL PRODUITS");
|
||||
addAmountCell(tableProduits, totalProduits, COLOR_TOTAL_ROW);
|
||||
doc.add(tableProduits);
|
||||
|
||||
doc.add(new Paragraph(" "));
|
||||
|
||||
// Section CHARGES
|
||||
addSectionTitle(doc, "CHARGES D'EXPLOITATION");
|
||||
PdfPTable tableCharges = creerTableau2Colonnes();
|
||||
for (Object[] ligne : lignesCharges) {
|
||||
addDataCell(tableCharges, ligne[0] + " — " + ligne[1], Color.WHITE);
|
||||
addAmountCell(tableCharges, (BigDecimal) ligne[2], Color.WHITE);
|
||||
}
|
||||
addTotalCell(tableCharges, "TOTAL CHARGES");
|
||||
addAmountCell(tableCharges, totalCharges, COLOR_TOTAL_ROW);
|
||||
doc.add(tableCharges);
|
||||
|
||||
doc.add(new Paragraph(" "));
|
||||
|
||||
// Résultat net
|
||||
PdfPTable tableResultat = creerTableau2Colonnes();
|
||||
String libelleResultat = benefice ? "BÉNÉFICE NET DE L'EXERCICE" : "PERTE NETTE DE L'EXERCICE";
|
||||
Color couleurResultat = benefice ? new Color(0x00, 0x80, 0x00) : new Color(0xCC, 0x00, 0x00);
|
||||
PdfPCell cellResultat = new PdfPCell(
|
||||
new Phrase(libelleResultat, FontFactory.getFont(FontFactory.HELVETICA_BOLD, 11, couleurResultat)));
|
||||
cellResultat.setBackgroundColor(new Color(0xF0, 0xF8, 0xE8));
|
||||
cellResultat.setPadding(8);
|
||||
tableResultat.addCell(cellResultat);
|
||||
addAmountCell(tableResultat, resultat.abs(), new Color(0xF0, 0xF8, 0xE8));
|
||||
doc.add(tableResultat);
|
||||
|
||||
addFooter(doc);
|
||||
doc.close();
|
||||
return baos.toByteArray();
|
||||
} catch (Exception e) {
|
||||
log.error("Erreur génération compte de résultat PDF : {}", e.getMessage(), e);
|
||||
throw new RuntimeException("Erreur génération compte de résultat PDF", e);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Grand livre ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Génère le grand livre pour un compte donné.
|
||||
*/
|
||||
public byte[] genererGrandLivre(UUID organisationId, String numeroCompte,
|
||||
LocalDate dateDebut, LocalDate dateFin) {
|
||||
Organisation org = getOrg(organisationId);
|
||||
CompteComptable compte = compteComptableRepository
|
||||
.findByOrganisationAndNumero(organisationId, numeroCompte)
|
||||
.orElseThrow(() -> new NotFoundException(
|
||||
"Compte " + numeroCompte + " introuvable pour l'org " + organisationId));
|
||||
|
||||
List<EcritureComptable> ecritures = ecritureComptableRepository
|
||||
.findByOrganisationAndDateRange(organisationId, dateDebut, dateFin);
|
||||
|
||||
// Filtrer les lignes qui concernent ce compte
|
||||
List<Object[]> mouvements = new ArrayList<>();
|
||||
BigDecimal solde = BigDecimal.ZERO;
|
||||
|
||||
for (EcritureComptable ecriture : ecritures) {
|
||||
if (ecriture.getLignes() == null) continue;
|
||||
for (LigneEcriture ligne : ecriture.getLignes()) {
|
||||
if (ligne.getCompteComptable() == null) continue;
|
||||
if (!numeroCompte.equals(ligne.getCompteComptable().getNumeroCompte())) continue;
|
||||
|
||||
BigDecimal debit = ligne.getMontantDebit() != null ? ligne.getMontantDebit() : BigDecimal.ZERO;
|
||||
BigDecimal credit = ligne.getMontantCredit() != null ? ligne.getMontantCredit() : BigDecimal.ZERO;
|
||||
solde = solde.add(debit).subtract(credit);
|
||||
|
||||
mouvements.add(new Object[]{
|
||||
ecriture.getDateEcriture(),
|
||||
ecriture.getNumeroPiece(),
|
||||
ecriture.getLibelle(),
|
||||
debit,
|
||||
credit,
|
||||
solde
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
|
||||
Document doc = new Document(PageSize.A4.rotate(), 20, 20, 40, 40);
|
||||
PdfWriter.getInstance(doc, baos);
|
||||
doc.open();
|
||||
|
||||
addTitrePage(doc, "GRAND LIVRE — " + numeroCompte + " " + compte.getLibelle(),
|
||||
org.getNom(), dateDebut, dateFin);
|
||||
|
||||
PdfPTable table = new PdfPTable(6);
|
||||
table.setWidthPercentage(100);
|
||||
table.setWidths(new float[]{12f, 15f, 35f, 12f, 12f, 14f});
|
||||
|
||||
addHeaderCell(table, "Date");
|
||||
addHeaderCell(table, "Pièce");
|
||||
addHeaderCell(table, "Libellé");
|
||||
addHeaderCell(table, "Débit");
|
||||
addHeaderCell(table, "Crédit");
|
||||
addHeaderCell(table, "Solde cumulé");
|
||||
|
||||
boolean alt = false;
|
||||
for (Object[] mvt : mouvements) {
|
||||
Color bg = alt ? COLOR_ROW_ALT : Color.WHITE;
|
||||
addDataCell(table, DATE_FMT.format((LocalDate) mvt[0]), bg);
|
||||
addDataCell(table, (String) mvt[1], bg);
|
||||
addDataCell(table, (String) mvt[2], bg);
|
||||
addAmountCell(table, (BigDecimal) mvt[3], bg);
|
||||
addAmountCell(table, (BigDecimal) mvt[4], bg);
|
||||
addAmountCell(table, (BigDecimal) mvt[5], bg);
|
||||
alt = !alt;
|
||||
}
|
||||
|
||||
if (mouvements.isEmpty()) {
|
||||
PdfPCell empty = new PdfPCell(new Phrase("Aucun mouvement sur la période",
|
||||
FontFactory.getFont(FontFactory.HELVETICA_OBLIQUE, 10, Color.GRAY)));
|
||||
empty.setColspan(6);
|
||||
empty.setPadding(10);
|
||||
empty.setHorizontalAlignment(Element.ALIGN_CENTER);
|
||||
table.addCell(empty);
|
||||
}
|
||||
|
||||
doc.add(table);
|
||||
addFooter(doc);
|
||||
doc.close();
|
||||
return baos.toByteArray();
|
||||
} catch (Exception e) {
|
||||
log.error("Erreur génération grand livre PDF : {}", e.getMessage(), e);
|
||||
throw new RuntimeException("Erreur génération grand livre PDF", e);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Utilitaires PDF ──────────────────────────────────────────────────────
|
||||
|
||||
private void addTitrePage(Document doc, String titre, String orgNom,
|
||||
LocalDate dateDebut, LocalDate dateFin) throws DocumentException {
|
||||
Font fontTitre = FontFactory.getFont(FontFactory.HELVETICA_BOLD, 16, COLOR_HEADER);
|
||||
Font fontSousTitre = FontFactory.getFont(FontFactory.HELVETICA, 11, Color.DARK_GRAY);
|
||||
|
||||
Paragraph pTitre = new Paragraph(titre, fontTitre);
|
||||
pTitre.setAlignment(Element.ALIGN_CENTER);
|
||||
pTitre.setSpacingAfter(4);
|
||||
doc.add(pTitre);
|
||||
|
||||
Paragraph pOrg = new Paragraph(orgNom, fontSousTitre);
|
||||
pOrg.setAlignment(Element.ALIGN_CENTER);
|
||||
doc.add(pOrg);
|
||||
|
||||
if (dateDebut != null && dateFin != null) {
|
||||
Paragraph pPeriode = new Paragraph(
|
||||
"Période : " + DATE_FMT.format(dateDebut) + " au " + DATE_FMT.format(dateFin),
|
||||
FontFactory.getFont(FontFactory.HELVETICA_OBLIQUE, 10, Color.GRAY));
|
||||
pPeriode.setAlignment(Element.ALIGN_CENTER);
|
||||
pPeriode.setSpacingAfter(12);
|
||||
doc.add(pPeriode);
|
||||
}
|
||||
}
|
||||
|
||||
private void addSectionTitle(Document doc, String titre) throws DocumentException {
|
||||
Paragraph p = new Paragraph(titre,
|
||||
FontFactory.getFont(FontFactory.HELVETICA_BOLD, 12, COLOR_HEADER));
|
||||
p.setSpacingBefore(8);
|
||||
p.setSpacingAfter(4);
|
||||
doc.add(p);
|
||||
}
|
||||
|
||||
private void addFooter(Document doc) throws DocumentException {
|
||||
Paragraph footer = new Paragraph(
|
||||
"Généré le " + DATE_FMT.format(LocalDate.now()) + " — UnionFlow SYSCOHADA révisé",
|
||||
FontFactory.getFont(FontFactory.HELVETICA_OBLIQUE, 8, Color.GRAY));
|
||||
footer.setAlignment(Element.ALIGN_RIGHT);
|
||||
footer.setSpacingBefore(16);
|
||||
doc.add(footer);
|
||||
}
|
||||
|
||||
private PdfPTable creerTableau2Colonnes() throws DocumentException {
|
||||
PdfPTable table = new PdfPTable(2);
|
||||
table.setWidthPercentage(100);
|
||||
table.setWidths(new float[]{65f, 35f});
|
||||
return table;
|
||||
}
|
||||
|
||||
private void addHeaderCell(PdfPTable table, String text) {
|
||||
PdfPCell cell = new PdfPCell(new Phrase(text,
|
||||
FontFactory.getFont(FontFactory.HELVETICA_BOLD, 9, COLOR_HEADER_TEXT)));
|
||||
cell.setBackgroundColor(COLOR_HEADER);
|
||||
cell.setPadding(6);
|
||||
cell.setHorizontalAlignment(Element.ALIGN_CENTER);
|
||||
table.addCell(cell);
|
||||
}
|
||||
|
||||
private void addDataCell(PdfPTable table, String text, Color bg) {
|
||||
PdfPCell cell = new PdfPCell(new Phrase(text,
|
||||
FontFactory.getFont(FontFactory.HELVETICA, 9, Color.BLACK)));
|
||||
cell.setBackgroundColor(bg);
|
||||
cell.setPadding(5);
|
||||
table.addCell(cell);
|
||||
}
|
||||
|
||||
private void addAmountCell(PdfPTable table, BigDecimal amount, Color bg) {
|
||||
String formatted = amount != null
|
||||
? String.format("%,.0f XOF", amount.doubleValue())
|
||||
: "0 XOF";
|
||||
PdfPCell cell = new PdfPCell(new Phrase(formatted,
|
||||
FontFactory.getFont(FontFactory.HELVETICA, 9, Color.BLACK)));
|
||||
cell.setBackgroundColor(bg);
|
||||
cell.setPadding(5);
|
||||
cell.setHorizontalAlignment(Element.ALIGN_RIGHT);
|
||||
table.addCell(cell);
|
||||
}
|
||||
|
||||
private void addTotalCell(PdfPTable table, String text) {
|
||||
PdfPCell cell = new PdfPCell(new Phrase(text,
|
||||
FontFactory.getFont(FontFactory.HELVETICA_BOLD, 9, Color.BLACK)));
|
||||
cell.setBackgroundColor(COLOR_TOTAL_ROW);
|
||||
cell.setPadding(6);
|
||||
table.addCell(cell);
|
||||
}
|
||||
|
||||
// ── Calcul des totaux ─────────────────────────────────────────────────────
|
||||
|
||||
private Map<String, BigDecimal[]> calculerTotauxParCompte(UUID organisationId,
|
||||
LocalDate dateDebut, LocalDate dateFin) {
|
||||
List<EcritureComptable> ecritures = ecritureComptableRepository
|
||||
.findByOrganisationAndDateRange(organisationId, dateDebut, dateFin);
|
||||
|
||||
Map<String, BigDecimal[]> totaux = new HashMap<>();
|
||||
for (EcritureComptable ecriture : ecritures) {
|
||||
if (ecriture.getLignes() == null) continue;
|
||||
for (LigneEcriture ligne : ecriture.getLignes()) {
|
||||
if (ligne.getCompteComptable() == null) continue;
|
||||
String numero = ligne.getCompteComptable().getNumeroCompte();
|
||||
totaux.computeIfAbsent(numero, k -> new BigDecimal[]{BigDecimal.ZERO, BigDecimal.ZERO});
|
||||
BigDecimal debit = ligne.getMontantDebit() != null ? ligne.getMontantDebit() : BigDecimal.ZERO;
|
||||
BigDecimal credit = ligne.getMontantCredit() != null ? ligne.getMontantCredit() : BigDecimal.ZERO;
|
||||
totaux.get(numero)[0] = totaux.get(numero)[0].add(debit);
|
||||
totaux.get(numero)[1] = totaux.get(numero)[1].add(credit);
|
||||
}
|
||||
}
|
||||
return totaux;
|
||||
}
|
||||
|
||||
private Organisation getOrg(UUID organisationId) {
|
||||
return organisationRepository.findByIdOptional(organisationId)
|
||||
.orElseThrow(() -> new NotFoundException("Organisation introuvable : " + organisationId));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package dev.lions.unionflow.server.service;
|
||||
|
||||
import io.quarkus.mailer.MailTemplate.MailTemplateInstance;
|
||||
import io.quarkus.qute.CheckedTemplate;
|
||||
import io.quarkus.qute.TemplateInstance;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.inject.Inject;
|
||||
import io.quarkus.mailer.Mail;
|
||||
import io.quarkus.mailer.Mailer;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
|
||||
/**
|
||||
* Service d'envoi d'emails HTML via Qute templates (Quarkus Mailer).
|
||||
*
|
||||
* <p>Templates : {@code src/main/resources/templates/email/}.
|
||||
* Variables injectées au moment de l'appel via {@code .data(key, value)}.
|
||||
*/
|
||||
@Slf4j
|
||||
@ApplicationScoped
|
||||
public class EmailTemplateService {
|
||||
|
||||
private static final DateTimeFormatter DATE_FR = DateTimeFormatter.ofPattern("dd/MM/yyyy");
|
||||
|
||||
@Inject
|
||||
Mailer mailer;
|
||||
|
||||
// ── Templates Qute ────────────────────────────────────────────────────────
|
||||
|
||||
@CheckedTemplate(basePath = "email")
|
||||
static class Templates {
|
||||
static native MailTemplateInstance bienvenue(
|
||||
String prenom, String nom, String email,
|
||||
String nomOrganisation, String lienConnexion);
|
||||
|
||||
static native MailTemplateInstance cotisationConfirmation(
|
||||
String prenom, String nom,
|
||||
String nomOrganisation, String periode,
|
||||
String numeroReference, String methodePaiement,
|
||||
String datePaiement, String montant);
|
||||
|
||||
static native MailTemplateInstance rappelCotisation(
|
||||
String prenom, String nom,
|
||||
String nomOrganisation, String periode,
|
||||
String montant, String dateLimite, String lienPaiement);
|
||||
|
||||
static native MailTemplateInstance souscriptionConfirmation(
|
||||
String nomAdministrateur, String nomOrganisation,
|
||||
String nomFormule, String montant, String periodicite,
|
||||
String dateActivation, String dateExpiration,
|
||||
String maxMembres, String maxStockageMo,
|
||||
boolean apiAccess, boolean supportPrioritaire);
|
||||
}
|
||||
|
||||
// ── Méthodes d'envoi ─────────────────────────────────────────────────────
|
||||
|
||||
public void envoyerBienvenue(String email, String prenom, String nom,
|
||||
String nomOrganisation, String lienConnexion) {
|
||||
try {
|
||||
Templates.bienvenue(prenom, nom, email, nomOrganisation, lienConnexion)
|
||||
.to(email)
|
||||
.subject("Bienvenue sur UnionFlow — " + nomOrganisation)
|
||||
.send().await().indefinitely();
|
||||
log.info("Email bienvenue envoyé à {}", email);
|
||||
} catch (Exception e) {
|
||||
log.error("Échec envoi email bienvenue à {}: {}", email, e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
public void envoyerConfirmationCotisation(String email, String prenom, String nom,
|
||||
String nomOrganisation, String periode,
|
||||
String numeroReference, String methodePaiement,
|
||||
LocalDate datePaiement, BigDecimal montant) {
|
||||
try {
|
||||
Templates.cotisationConfirmation(
|
||||
prenom, nom, nomOrganisation, periode,
|
||||
numeroReference, methodePaiement,
|
||||
datePaiement != null ? DATE_FR.format(datePaiement) : "—",
|
||||
String.format("%,.0f", montant.doubleValue()))
|
||||
.to(email)
|
||||
.subject("Confirmation de cotisation — " + periode)
|
||||
.send().await().indefinitely();
|
||||
log.info("Email confirmation cotisation envoyé à {}", email);
|
||||
} catch (Exception e) {
|
||||
log.error("Échec envoi email cotisation à {}: {}", email, e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
public void envoyerRappelCotisation(String email, String prenom, String nom,
|
||||
String nomOrganisation, String periode,
|
||||
BigDecimal montant, LocalDate dateLimite,
|
||||
String lienPaiement) {
|
||||
try {
|
||||
Templates.rappelCotisation(
|
||||
prenom, nom, nomOrganisation, periode,
|
||||
String.format("%,.0f", montant.doubleValue()),
|
||||
dateLimite != null ? DATE_FR.format(dateLimite) : "—",
|
||||
lienPaiement)
|
||||
.to(email)
|
||||
.subject("⚠️ Rappel : cotisation " + periode + " en attente")
|
||||
.send().await().indefinitely();
|
||||
log.info("Email rappel cotisation envoyé à {}", email);
|
||||
} catch (Exception e) {
|
||||
log.error("Échec envoi rappel cotisation à {}: {}", email, e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
public void envoyerConfirmationSouscription(String email, String nomAdministrateur,
|
||||
String nomOrganisation, String nomFormule,
|
||||
BigDecimal montant, String periodicite,
|
||||
LocalDate dateActivation, LocalDate dateExpiration,
|
||||
Integer maxMembres, Integer maxStockageMo,
|
||||
boolean apiAccess, boolean supportPrioritaire) {
|
||||
try {
|
||||
Templates.souscriptionConfirmation(
|
||||
nomAdministrateur, nomOrganisation, nomFormule,
|
||||
String.format("%,.0f", montant.doubleValue()), periodicite,
|
||||
dateActivation != null ? DATE_FR.format(dateActivation) : "—",
|
||||
dateExpiration != null ? DATE_FR.format(dateExpiration) : "—",
|
||||
maxMembres != null ? String.valueOf(maxMembres) : "Illimité",
|
||||
maxStockageMo != null ? String.valueOf(maxStockageMo) : "1024",
|
||||
apiAccess, supportPrioritaire)
|
||||
.to(email)
|
||||
.subject("✅ Souscription activée — " + nomOrganisation)
|
||||
.send().await().indefinitely();
|
||||
log.info("Email confirmation souscription envoyé à {}", email);
|
||||
} catch (Exception e) {
|
||||
log.error("Échec envoi email souscription à {}: {}", email, e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package dev.lions.unionflow.server.service;
|
||||
|
||||
import com.google.auth.oauth2.GoogleCredentials;
|
||||
import com.google.firebase.FirebaseApp;
|
||||
import com.google.firebase.FirebaseOptions;
|
||||
import com.google.firebase.messaging.*;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.eclipse.microprofile.config.inject.ConfigProperty;
|
||||
|
||||
import java.io.FileInputStream;
|
||||
import java.io.InputStream;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Service d'envoi de notifications push via Firebase Cloud Messaging (FCM).
|
||||
*
|
||||
* <p>Configuration requise (application-prod.properties) :
|
||||
* <pre>
|
||||
* firebase.service-account-key-path=/opt/unionflow/firebase-service-account.json
|
||||
* </pre>
|
||||
*
|
||||
* <p>En dev/test, le service est désactivé si le fichier n'existe pas.
|
||||
*/
|
||||
@Slf4j
|
||||
@ApplicationScoped
|
||||
public class FirebasePushService {
|
||||
|
||||
@ConfigProperty(name = "firebase.service-account-key-path")
|
||||
Optional<String> serviceAccountKeyPathOpt;
|
||||
|
||||
String serviceAccountKeyPath;
|
||||
private boolean initialized = false;
|
||||
|
||||
@PostConstruct
|
||||
void init() {
|
||||
serviceAccountKeyPath = serviceAccountKeyPathOpt.orElse("");
|
||||
if (serviceAccountKeyPath.isBlank()) {
|
||||
log.info("Firebase FCM désactivé (firebase.service-account-key-path non configuré)");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (FirebaseApp.getApps().isEmpty()) {
|
||||
InputStream serviceAccount = new FileInputStream(serviceAccountKeyPath);
|
||||
FirebaseOptions options = FirebaseOptions.builder()
|
||||
.setCredentials(GoogleCredentials.fromStream(serviceAccount))
|
||||
.build();
|
||||
FirebaseApp.initializeApp(options);
|
||||
}
|
||||
initialized = true;
|
||||
log.info("Firebase FCM initialisé depuis {}", serviceAccountKeyPath);
|
||||
} catch (Exception e) {
|
||||
log.warn("Firebase FCM non initialisé ({}): {} — les notifications push seront ignorées",
|
||||
serviceAccountKeyPath, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Envoie une notification push à un token FCM unique.
|
||||
*
|
||||
* @param token token FCM du device cible
|
||||
* @param titre titre de la notification
|
||||
* @param corps corps de la notification
|
||||
* @param data données supplémentaires (payload JSON key/value)
|
||||
* @return `true` si envoi réussi, `false` sinon
|
||||
*/
|
||||
public boolean envoyerNotification(String token, String titre, String corps,
|
||||
java.util.Map<String, String> data) {
|
||||
if (!initialized || token == null || token.isBlank()) return false;
|
||||
|
||||
try {
|
||||
Message.Builder builder = Message.builder()
|
||||
.setToken(token)
|
||||
.setNotification(Notification.builder()
|
||||
.setTitle(titre)
|
||||
.setBody(corps)
|
||||
.build());
|
||||
|
||||
if (data != null && !data.isEmpty()) {
|
||||
builder.putAllData(data);
|
||||
}
|
||||
|
||||
String response = FirebaseMessaging.getInstance().send(builder.build());
|
||||
log.info("FCM push envoyé : messageId={}", response);
|
||||
return true;
|
||||
} catch (FirebaseMessagingException e) {
|
||||
if (MessagingErrorCode.UNREGISTERED.equals(e.getMessagingErrorCode())
|
||||
|| MessagingErrorCode.INVALID_ARGUMENT.equals(e.getMessagingErrorCode())) {
|
||||
log.warn("Token FCM invalide/expiré : {}", token);
|
||||
} else {
|
||||
log.error("Erreur FCM pour token {}: {} ({})", token, e.getMessage(), e.getMessagingErrorCode());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Envoie une notification push à une liste de tokens (multicast, max 500).
|
||||
*
|
||||
* @return nombre de messages envoyés avec succès
|
||||
*/
|
||||
public int envoyerNotificationMulticast(List<String> tokens, String titre, String corps,
|
||||
java.util.Map<String, String> data) {
|
||||
if (!initialized || tokens == null || tokens.isEmpty()) return 0;
|
||||
|
||||
// FCM multicast : max 500 tokens par appel
|
||||
List<String> validTokens = tokens.stream()
|
||||
.filter(t -> t != null && !t.isBlank())
|
||||
.limit(500)
|
||||
.toList();
|
||||
if (validTokens.isEmpty()) return 0;
|
||||
|
||||
try {
|
||||
MulticastMessage.Builder builder = MulticastMessage.builder()
|
||||
.addAllTokens(validTokens)
|
||||
.setNotification(Notification.builder()
|
||||
.setTitle(titre)
|
||||
.setBody(corps)
|
||||
.build());
|
||||
|
||||
if (data != null && !data.isEmpty()) {
|
||||
builder.putAllData(data);
|
||||
}
|
||||
|
||||
BatchResponse response = FirebaseMessaging.getInstance().sendEachForMulticast(builder.build());
|
||||
log.info("FCM multicast : {}/{} envoyés avec succès", response.getSuccessCount(), validTokens.size());
|
||||
return response.getSuccessCount();
|
||||
} catch (FirebaseMessagingException e) {
|
||||
log.error("Erreur FCM multicast : {}", e.getMessage(), e);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isAvailable() {
|
||||
return initialized;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
package dev.lions.unionflow.server.service;
|
||||
|
||||
import dev.lions.unionflow.server.api.dto.kyc.KycDossierRequest;
|
||||
import dev.lions.unionflow.server.api.dto.kyc.KycDossierResponse;
|
||||
import dev.lions.unionflow.server.api.enums.membre.NiveauRisqueKyc;
|
||||
import dev.lions.unionflow.server.api.enums.membre.StatutKyc;
|
||||
import dev.lions.unionflow.server.entity.KycDossier;
|
||||
import dev.lions.unionflow.server.entity.Membre;
|
||||
import dev.lions.unionflow.server.repository.KycDossierRepository;
|
||||
import dev.lions.unionflow.server.repository.MembreRepository;
|
||||
import dev.lions.unionflow.server.security.RlsEnabled;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.transaction.Transactional;
|
||||
import jakarta.ws.rs.NotFoundException;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Service KYC/AML (Know Your Customer / Anti-Money Laundering).
|
||||
*
|
||||
* <p>Implémente la due diligence requise par le GIABA (Groupe Intergouvernemental
|
||||
* d'Action contre le Blanchiment d'Argent en Afrique de l'Ouest) et les
|
||||
* instructions BCEAO sur la LCB-FT (Lutte Contre le Blanchiment et le Financement
|
||||
* du Terrorisme).
|
||||
*
|
||||
* <p>Algorithme de score de risque :
|
||||
* <ul>
|
||||
* <li>PEP (Personne Exposée Politiquement) : +40 points</li>
|
||||
* <li>Pièce expirée : +20 points</li>
|
||||
* <li>Aucun justificatif de domicile : +15 points</li>
|
||||
* <li>Pièce manquante (recto/verso) : +15 points</li>
|
||||
* <li>Nationalité hors UEMOA (facteur risque géographique) : +10 points</li>
|
||||
* </ul>
|
||||
*/
|
||||
@Slf4j
|
||||
@ApplicationScoped
|
||||
@RlsEnabled
|
||||
public class KycAmlService {
|
||||
|
||||
private static final List<String> PAYS_UEMOA = List.of(
|
||||
"BJ", "BF", "CI", "GW", "ML", "NE", "SN", "TG"
|
||||
);
|
||||
|
||||
@Inject
|
||||
KycDossierRepository kycDossierRepository;
|
||||
|
||||
@Inject
|
||||
MembreRepository membreRepository;
|
||||
|
||||
@Inject
|
||||
AuditService auditService;
|
||||
|
||||
/**
|
||||
* Crée ou met à jour le dossier KYC d'un membre.
|
||||
* Si un dossier actif existe déjà, il est archivé et remplacé.
|
||||
*/
|
||||
@Transactional
|
||||
public KycDossierResponse soumettreOuMettreAJour(KycDossierRequest request, String operateur) {
|
||||
UUID membreId = UUID.fromString(request.getMembreId());
|
||||
Membre membre = membreRepository.findByIdOptional(membreId)
|
||||
.orElseThrow(() -> new NotFoundException("Membre introuvable : " + request.getMembreId()));
|
||||
|
||||
// Archiver l'ancien dossier actif si présent
|
||||
kycDossierRepository.findDossierActifByMembre(membreId).ifPresent(ancien -> {
|
||||
ancien.setActif(false);
|
||||
ancien.setModifiePar(operateur);
|
||||
kycDossierRepository.persist(ancien);
|
||||
});
|
||||
|
||||
KycDossier dossier = KycDossier.builder()
|
||||
.membre(membre)
|
||||
.typePiece(request.getTypePiece())
|
||||
.numeroPiece(request.getNumeroPiece())
|
||||
.dateExpirationPiece(request.getDateExpirationPiece())
|
||||
.pieceIdentiteRectoFileId(request.getPieceIdentiteRectoFileId())
|
||||
.pieceIdentiteVersoFileId(request.getPieceIdentiteVersoFileId())
|
||||
.justifDomicileFileId(request.getJustifDomicileFileId())
|
||||
.estPep(Boolean.TRUE.equals(request.getEstPep()))
|
||||
.nationalite(request.getNationalite())
|
||||
.notesValidateur(request.getNotesValidateur())
|
||||
.statut(StatutKyc.EN_COURS)
|
||||
.anneeReference(LocalDate.now().getYear())
|
||||
.build();
|
||||
|
||||
dossier.setCreePar(operateur);
|
||||
kycDossierRepository.persist(dossier);
|
||||
|
||||
log.info("Dossier KYC soumis pour membre {} par {}", membreId, operateur);
|
||||
return toDto(dossier);
|
||||
}
|
||||
|
||||
/**
|
||||
* Évalue le score de risque LCB-FT du membre et met à jour son dossier.
|
||||
*
|
||||
* @param membreId l'UUID du membre
|
||||
* @return le dossier KYC mis à jour avec le score et niveau de risque
|
||||
*/
|
||||
@Transactional
|
||||
public KycDossierResponse evaluerRisque(UUID membreId) {
|
||||
KycDossier dossier = kycDossierRepository.findDossierActifByMembre(membreId)
|
||||
.orElseThrow(() -> new NotFoundException("Aucun dossier KYC actif pour le membre : " + membreId));
|
||||
|
||||
int score = calculerScore(dossier);
|
||||
NiveauRisqueKyc niveau = NiveauRisqueKyc.fromScore(score);
|
||||
|
||||
dossier.setScoreRisque(score);
|
||||
dossier.setNiveauRisque(niveau);
|
||||
kycDossierRepository.persist(dossier);
|
||||
|
||||
if (niveau == NiveauRisqueKyc.CRITIQUE || niveau == NiveauRisqueKyc.ELEVE) {
|
||||
log.warn("Membre {} : niveau risque KYC {} (score {})", membreId, niveau, score);
|
||||
auditService.logKycRisqueEleve(membreId, score, niveau.name());
|
||||
}
|
||||
|
||||
return toDto(dossier);
|
||||
}
|
||||
|
||||
/**
|
||||
* Valide manuellement un dossier KYC (approbation par un agent habilité).
|
||||
*/
|
||||
@Transactional
|
||||
public KycDossierResponse valider(UUID dossierId, UUID validateurId, String notes, String operateur) {
|
||||
KycDossier dossier = kycDossierRepository.findByIdOptional(dossierId)
|
||||
.orElseThrow(() -> new NotFoundException("Dossier KYC introuvable : " + dossierId));
|
||||
|
||||
int score = calculerScore(dossier);
|
||||
dossier.setScoreRisque(score);
|
||||
dossier.setNiveauRisque(NiveauRisqueKyc.fromScore(score));
|
||||
dossier.setStatut(StatutKyc.VERIFIE);
|
||||
dossier.setDateVerification(LocalDateTime.now());
|
||||
dossier.setValidateurId(validateurId);
|
||||
dossier.setNotesValidateur(notes);
|
||||
dossier.setModifiePar(operateur);
|
||||
|
||||
kycDossierRepository.persist(dossier);
|
||||
log.info("Dossier KYC {} validé par {} (score={})", dossierId, validateurId, score);
|
||||
return toDto(dossier);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuse un dossier KYC avec motif.
|
||||
*/
|
||||
@Transactional
|
||||
public KycDossierResponse refuser(UUID dossierId, UUID validateurId, String motif, String operateur) {
|
||||
KycDossier dossier = kycDossierRepository.findByIdOptional(dossierId)
|
||||
.orElseThrow(() -> new NotFoundException("Dossier KYC introuvable : " + dossierId));
|
||||
|
||||
dossier.setStatut(StatutKyc.REFUSE);
|
||||
dossier.setDateVerification(LocalDateTime.now());
|
||||
dossier.setValidateurId(validateurId);
|
||||
dossier.setNotesValidateur(motif);
|
||||
dossier.setModifiePar(operateur);
|
||||
|
||||
kycDossierRepository.persist(dossier);
|
||||
log.info("Dossier KYC {} refusé par {}: {}", dossierId, validateurId, motif);
|
||||
return toDto(dossier);
|
||||
}
|
||||
|
||||
public Optional<KycDossierResponse> getDossierActif(UUID membreId) {
|
||||
return kycDossierRepository.findDossierActifByMembre(membreId).map(this::toDto);
|
||||
}
|
||||
|
||||
public List<KycDossierResponse> getDossiersEnAttente() {
|
||||
return kycDossierRepository.findByStatut(StatutKyc.EN_COURS)
|
||||
.stream().map(this::toDto).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
public List<KycDossierResponse> getDossiersPep() {
|
||||
return kycDossierRepository.findPep()
|
||||
.stream().map(this::toDto).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
public List<KycDossierResponse> getPiecesExpirantDansLes30Jours() {
|
||||
LocalDate limite = LocalDate.now().plusDays(30);
|
||||
return kycDossierRepository.findPiecesExpirantsAvant(limite)
|
||||
.stream().map(this::toDto).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
// ── Calcul score ────────────────────────────────────────────────────────────
|
||||
|
||||
int calculerScore(KycDossier dossier) {
|
||||
int score = 0;
|
||||
|
||||
if (dossier.isEstPep()) {
|
||||
score += 40;
|
||||
}
|
||||
|
||||
if (dossier.isPieceExpiree()) {
|
||||
score += 20;
|
||||
}
|
||||
|
||||
if (dossier.getJustifDomicileFileId() == null || dossier.getJustifDomicileFileId().isBlank()) {
|
||||
score += 15;
|
||||
}
|
||||
|
||||
boolean rectoManquant = dossier.getPieceIdentiteRectoFileId() == null
|
||||
|| dossier.getPieceIdentiteRectoFileId().isBlank();
|
||||
boolean versoManquant = dossier.getPieceIdentiteVersoFileId() == null
|
||||
|| dossier.getPieceIdentiteVersoFileId().isBlank();
|
||||
if (rectoManquant || versoManquant) {
|
||||
score += 15;
|
||||
}
|
||||
|
||||
if (dossier.getNationalite() != null && !PAYS_UEMOA.contains(dossier.getNationalite().toUpperCase())) {
|
||||
score += 10;
|
||||
}
|
||||
|
||||
return Math.min(score, 100);
|
||||
}
|
||||
|
||||
// ── Mapping ─────────────────────────────────────────────────────────────────
|
||||
|
||||
private KycDossierResponse toDto(KycDossier d) {
|
||||
KycDossierResponse dto = new KycDossierResponse();
|
||||
dto.setId(d.getId());
|
||||
dto.setDateCreation(d.getDateCreation());
|
||||
dto.setDateModification(d.getDateModification());
|
||||
dto.setCreePar(d.getCreePar());
|
||||
dto.setModifiePar(d.getModifiePar());
|
||||
dto.setVersion(d.getVersion());
|
||||
dto.setActif(d.getActif());
|
||||
|
||||
if (d.getMembre() != null) {
|
||||
dto.setMembreId(d.getMembre().getId());
|
||||
dto.setMembreNomComplet(d.getMembre().getPrenom() + " " + d.getMembre().getNom());
|
||||
dto.setMembreEmail(d.getMembre().getEmail());
|
||||
}
|
||||
|
||||
dto.setTypePiece(d.getTypePiece());
|
||||
dto.setNumeroPiece(d.getNumeroPiece());
|
||||
dto.setDateExpirationPiece(d.getDateExpirationPiece());
|
||||
dto.setPieceIdentiteRectoFileId(d.getPieceIdentiteRectoFileId());
|
||||
dto.setPieceIdentiteVersoFileId(d.getPieceIdentiteVersoFileId());
|
||||
dto.setJustifDomicileFileId(d.getJustifDomicileFileId());
|
||||
dto.setStatut(d.getStatut());
|
||||
dto.setNiveauRisque(d.getNiveauRisque());
|
||||
dto.setScoreRisque(d.getScoreRisque());
|
||||
dto.setEstPep(d.isEstPep());
|
||||
dto.setNationalite(d.getNationalite());
|
||||
dto.setDateVerification(d.getDateVerification());
|
||||
dto.setValidateurId(d.getValidateurId());
|
||||
dto.setNotesValidateur(d.getNotesValidateur());
|
||||
dto.setAnneeReference(d.getAnneeReference());
|
||||
return dto;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
package dev.lions.unionflow.server.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ArrayNode;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import dev.lions.unionflow.server.entity.MembreOrganisation;
|
||||
import dev.lions.unionflow.server.entity.Organisation;
|
||||
import dev.lions.unionflow.server.repository.MembreOrganisationRepository;
|
||||
import dev.lions.unionflow.server.repository.OrganisationRepository;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.transaction.Transactional;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.eclipse.microprofile.config.inject.ConfigProperty;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.text.Normalizer;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Service de migration one-shot : crée les Keycloak 26 Organizations correspondant
|
||||
* à chaque Organisation UnionFlow, assigne les rôles standards et migre les memberships.
|
||||
*
|
||||
* <p>Idempotent : si {@code keycloak_org_id} est déjà renseigné pour une org,
|
||||
* elle est ignorée (pas de doublon).
|
||||
*
|
||||
* <p>Déclenchement : endpoint admin {@code POST /api/admin/keycloak/migrer-organisations}.
|
||||
*/
|
||||
@Slf4j
|
||||
@ApplicationScoped
|
||||
public class MigrerOrganisationsVersKeycloakService {
|
||||
|
||||
/** Rôles Organization standards créés dans chaque Keycloak Organization. */
|
||||
private static final List<String> ROLES_STANDARDS = List.of(
|
||||
"ADMIN_ORGANISATION", "TRESORIER", "SECRETAIRE",
|
||||
"COMMISSAIRE_COMPTES", "MEMBRE_ACTIF"
|
||||
);
|
||||
|
||||
@ConfigProperty(name = "keycloak.admin.url", defaultValue = "http://localhost:8180")
|
||||
String keycloakUrl;
|
||||
|
||||
@ConfigProperty(name = "keycloak.admin.username", defaultValue = "admin")
|
||||
String adminUsername;
|
||||
|
||||
@ConfigProperty(name = "keycloak.admin.password", defaultValue = "admin")
|
||||
String adminPassword;
|
||||
|
||||
@ConfigProperty(name = "keycloak.admin.realm", defaultValue = "unionflow")
|
||||
String realm;
|
||||
|
||||
@Inject
|
||||
OrganisationRepository organisationRepository;
|
||||
|
||||
@Inject
|
||||
MembreOrganisationRepository membreOrganisationRepository;
|
||||
|
||||
private final HttpClient httpClient = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(10))
|
||||
.build();
|
||||
|
||||
private final ObjectMapper mapper = new ObjectMapper()
|
||||
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
|
||||
|
||||
/**
|
||||
* Point d'entrée principal — migre toutes les organisations sans {@code keycloak_org_id}.
|
||||
*
|
||||
* @return rapport de migration
|
||||
*/
|
||||
@Transactional
|
||||
public MigrationReport migrerToutesLesOrganisations() throws Exception {
|
||||
String token = getAdminToken();
|
||||
List<Organisation> orgs = organisationRepository.listAll();
|
||||
|
||||
int crees = 0, ignores = 0, erreurs = 0;
|
||||
|
||||
for (Organisation org : orgs) {
|
||||
if (org.getKeycloakOrgId() != null) {
|
||||
ignores++;
|
||||
log.debug("Org '{}' déjà migrée (kcOrgId={}), ignorée.", org.getNom(), org.getKeycloakOrgId());
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
UUID kcOrgId = creerOrganisationKeycloak(token, org);
|
||||
creerRolesOrganisation(token, kcOrgId);
|
||||
migrerMemberships(token, kcOrgId, org);
|
||||
|
||||
org.setKeycloakOrgId(kcOrgId);
|
||||
organisationRepository.persist(org);
|
||||
crees++;
|
||||
log.info("Organisation '{}' migrée → keycloak_org_id={}", org.getNom(), kcOrgId);
|
||||
|
||||
} catch (Exception e) {
|
||||
erreurs++;
|
||||
log.error("Échec migration org '{}' (id={}): {}", org.getNom(), org.getId(), e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
return new MigrationReport(orgs.size(), crees, ignores, erreurs);
|
||||
}
|
||||
|
||||
// ── Création Organization Keycloak ──────────────────────────────────────────
|
||||
|
||||
private UUID creerOrganisationKeycloak(String token, Organisation org) throws Exception {
|
||||
ObjectNode body = mapper.createObjectNode();
|
||||
body.put("name", org.getNom());
|
||||
body.put("alias", slugify(org.getNom()));
|
||||
body.put("enabled", true);
|
||||
|
||||
ObjectNode attrs = mapper.createObjectNode();
|
||||
attrs.putArray("unionflow_id").add(org.getId().toString());
|
||||
attrs.putArray("type_organisation").add(org.getTypeOrganisation() != null ? org.getTypeOrganisation() : "");
|
||||
if (org.getCategorieType() != null) {
|
||||
attrs.putArray("categorie").add(org.getCategorieType());
|
||||
}
|
||||
body.set("attributes", attrs);
|
||||
|
||||
// Ajouter le domaine email si disponible
|
||||
if (org.getEmail() != null) {
|
||||
String domaine = org.getEmail().contains("@") ? org.getEmail().split("@")[1] : "";
|
||||
if (!domaine.isBlank()) {
|
||||
ArrayNode domains = mapper.createArrayNode();
|
||||
ObjectNode domainObj = mapper.createObjectNode();
|
||||
domainObj.put("name", domaine);
|
||||
domainObj.put("verified", false);
|
||||
domains.add(domainObj);
|
||||
body.set("domains", domains);
|
||||
}
|
||||
}
|
||||
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(keycloakUrl + "/admin/realms/" + realm + "/organizations"))
|
||||
.header("Authorization", "Bearer " + token)
|
||||
.header("Content-Type", "application/json")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(mapper.writeValueAsString(body)))
|
||||
.timeout(Duration.ofSeconds(10))
|
||||
.build();
|
||||
|
||||
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
|
||||
// 201 Created → Location header contient l'URL avec l'ID
|
||||
if (response.statusCode() == 201) {
|
||||
String location = response.headers().firstValue("Location").orElseThrow(
|
||||
() -> new RuntimeException("Keycloak 201 mais sans header Location pour org: " + org.getNom()));
|
||||
String kcOrgId = location.substring(location.lastIndexOf('/') + 1);
|
||||
return UUID.fromString(kcOrgId);
|
||||
}
|
||||
|
||||
// 409 = alias déjà pris → chercher l'org existante par alias
|
||||
if (response.statusCode() == 409) {
|
||||
log.warn("Organisation '{}' déjà présente dans Keycloak (409), recherche par alias.", org.getNom());
|
||||
return chercherOrganisationParAlias(token, slugify(org.getNom()));
|
||||
}
|
||||
|
||||
throw new RuntimeException("Échec création Keycloak Org '" + org.getNom()
|
||||
+ "' (HTTP " + response.statusCode() + "): " + response.body());
|
||||
}
|
||||
|
||||
private UUID chercherOrganisationParAlias(String token, String alias) throws Exception {
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(keycloakUrl + "/admin/realms/" + realm + "/organizations?search=" + alias + "&max=10"))
|
||||
.header("Authorization", "Bearer " + token)
|
||||
.GET()
|
||||
.timeout(Duration.ofSeconds(10))
|
||||
.build();
|
||||
|
||||
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
if (response.statusCode() != 200) {
|
||||
throw new RuntimeException("Impossible de rechercher l'org par alias '" + alias + "'");
|
||||
}
|
||||
|
||||
var results = mapper.readTree(response.body());
|
||||
for (var node : results) {
|
||||
if (alias.equals(node.path("alias").asText())) {
|
||||
return UUID.fromString(node.path("id").asText());
|
||||
}
|
||||
}
|
||||
throw new RuntimeException("Organisation avec alias '" + alias + "' introuvable dans Keycloak.");
|
||||
}
|
||||
|
||||
// ── Création rôles standards ────────────────────────────────────────────────
|
||||
|
||||
private void creerRolesOrganisation(String token, UUID kcOrgId) throws Exception {
|
||||
for (String roleName : ROLES_STANDARDS) {
|
||||
ObjectNode roleBody = mapper.createObjectNode();
|
||||
roleBody.put("name", roleName);
|
||||
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(keycloakUrl + "/admin/realms/" + realm
|
||||
+ "/organizations/" + kcOrgId + "/roles"))
|
||||
.header("Authorization", "Bearer " + token)
|
||||
.header("Content-Type", "application/json")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(mapper.writeValueAsString(roleBody)))
|
||||
.timeout(Duration.ofSeconds(10))
|
||||
.build();
|
||||
|
||||
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
if (response.statusCode() != 201 && response.statusCode() != 409) {
|
||||
log.warn("Impossible de créer le rôle '{}' pour kcOrgId={} (HTTP {}): {}",
|
||||
roleName, kcOrgId, response.statusCode(), response.body());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Migration memberships ───────────────────────────────────────────────────
|
||||
|
||||
private void migrerMemberships(String token, UUID kcOrgId, Organisation org) {
|
||||
List<MembreOrganisation> memberships = membreOrganisationRepository
|
||||
.find("organisation.id = ?1 AND actif = true", org.getId())
|
||||
.list();
|
||||
|
||||
for (MembreOrganisation mo : memberships) {
|
||||
if (mo.getMembre() == null || mo.getMembre().getKeycloakId() == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
String kcUserId = mo.getMembre().getKeycloakId().toString();
|
||||
try {
|
||||
ajouterMembreKeycloakOrg(token, kcOrgId, kcUserId);
|
||||
|
||||
if (mo.getRoleOrg() != null && ROLES_STANDARDS.contains(mo.getRoleOrg())) {
|
||||
assignerRoleOrganisation(token, kcOrgId, kcUserId, mo.getRoleOrg());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("Impossible de migrer le membership keycloakId={} → kcOrg={}: {}",
|
||||
kcUserId, kcOrgId, e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ajouterMembreKeycloakOrg(String token, UUID kcOrgId, String kcUserId) throws Exception {
|
||||
ObjectNode body = mapper.createObjectNode();
|
||||
body.put("id", kcUserId);
|
||||
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(keycloakUrl + "/admin/realms/" + realm
|
||||
+ "/organizations/" + kcOrgId + "/members"))
|
||||
.header("Authorization", "Bearer " + token)
|
||||
.header("Content-Type", "application/json")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(mapper.writeValueAsString(body)))
|
||||
.timeout(Duration.ofSeconds(10))
|
||||
.build();
|
||||
|
||||
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
if (response.statusCode() != 201 && response.statusCode() != 409) {
|
||||
throw new RuntimeException("HTTP " + response.statusCode() + ": " + response.body());
|
||||
}
|
||||
}
|
||||
|
||||
private void assignerRoleOrganisation(String token, UUID kcOrgId, String kcUserId,
|
||||
String roleName) throws Exception {
|
||||
// 1. Récupérer l'ID du rôle
|
||||
HttpRequest getRoles = HttpRequest.newBuilder()
|
||||
.uri(URI.create(keycloakUrl + "/admin/realms/" + realm
|
||||
+ "/organizations/" + kcOrgId + "/roles"))
|
||||
.header("Authorization", "Bearer " + token)
|
||||
.GET()
|
||||
.timeout(Duration.ofSeconds(10))
|
||||
.build();
|
||||
|
||||
HttpResponse<String> rolesResponse = httpClient.send(getRoles, HttpResponse.BodyHandlers.ofString());
|
||||
if (rolesResponse.statusCode() != 200) return;
|
||||
|
||||
var roles = mapper.readTree(rolesResponse.body());
|
||||
String roleId = null;
|
||||
for (var role : roles) {
|
||||
if (roleName.equals(role.path("name").asText())) {
|
||||
roleId = role.path("id").asText();
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (roleId == null) return;
|
||||
|
||||
// 2. Assigner le rôle au membre
|
||||
ArrayNode assignBody = mapper.createArrayNode();
|
||||
ObjectNode roleRef = mapper.createObjectNode();
|
||||
roleRef.put("id", roleId);
|
||||
roleRef.put("name", roleName);
|
||||
assignBody.add(roleRef);
|
||||
|
||||
HttpRequest assignRequest = HttpRequest.newBuilder()
|
||||
.uri(URI.create(keycloakUrl + "/admin/realms/" + realm
|
||||
+ "/organizations/" + kcOrgId + "/members/" + kcUserId + "/roles"))
|
||||
.header("Authorization", "Bearer " + token)
|
||||
.header("Content-Type", "application/json")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(mapper.writeValueAsString(assignBody)))
|
||||
.timeout(Duration.ofSeconds(10))
|
||||
.build();
|
||||
|
||||
HttpResponse<String> assignResponse = httpClient.send(assignRequest, HttpResponse.BodyHandlers.ofString());
|
||||
if (assignResponse.statusCode() != 201 && assignResponse.statusCode() != 204) {
|
||||
log.warn("Impossible d'assigner le rôle '{}' à l'utilisateur {} (HTTP {})",
|
||||
roleName, kcUserId, assignResponse.statusCode());
|
||||
}
|
||||
}
|
||||
|
||||
// ── Auth admin ──────────────────────────────────────────────────────────────
|
||||
|
||||
private String getAdminToken() throws Exception {
|
||||
String body = "client_id=admin-cli"
|
||||
+ "&username=" + java.net.URLEncoder.encode(adminUsername, java.nio.charset.StandardCharsets.UTF_8)
|
||||
+ "&password=" + java.net.URLEncoder.encode(adminPassword, java.nio.charset.StandardCharsets.UTF_8)
|
||||
+ "&grant_type=password";
|
||||
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(keycloakUrl + "/realms/master/protocol/openid-connect/token"))
|
||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(body))
|
||||
.timeout(Duration.ofSeconds(10))
|
||||
.build();
|
||||
|
||||
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
if (response.statusCode() != 200) {
|
||||
throw new RuntimeException("Échec auth admin Keycloak (HTTP " + response.statusCode() + ")");
|
||||
}
|
||||
|
||||
return mapper.readTree(response.body()).get("access_token").asText();
|
||||
}
|
||||
|
||||
// ── Utilitaires ─────────────────────────────────────────────────────────────
|
||||
|
||||
private static final Pattern NON_ALPHANUMERIC = Pattern.compile("[^a-z0-9-]");
|
||||
private static final Pattern MULTIPLE_DASHES = Pattern.compile("-{2,}");
|
||||
|
||||
static String slugify(String input) {
|
||||
if (input == null) return "org-" + UUID.randomUUID().toString().substring(0, 8);
|
||||
String normalized = Normalizer.normalize(input.toLowerCase(), Normalizer.Form.NFD)
|
||||
.replaceAll("\\p{InCombiningDiacriticalMarks}+", "");
|
||||
String slug = NON_ALPHANUMERIC.matcher(normalized.replace(' ', '-')).replaceAll("");
|
||||
slug = MULTIPLE_DASHES.matcher(slug).replaceAll("-").replaceAll("^-|-$", "");
|
||||
return slug.isBlank() ? "org-" + UUID.randomUUID().toString().substring(0, 8) : slug;
|
||||
}
|
||||
|
||||
// ── Rapport ─────────────────────────────────────────────────────────────────
|
||||
|
||||
public record MigrationReport(int total, int crees, int ignores, int erreurs) {
|
||||
public boolean success() {
|
||||
return erreurs == 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
package dev.lions.unionflow.server.service.mutuelle;
|
||||
|
||||
import dev.lions.unionflow.server.api.enums.mutuelle.epargne.StatutCompteEpargne;
|
||||
import dev.lions.unionflow.server.api.enums.mutuelle.epargne.TypeTransactionEpargne;
|
||||
import dev.lions.unionflow.server.api.enums.mutuelle.parts.TypeTransactionPartsSociales;
|
||||
import dev.lions.unionflow.server.api.enums.wave.StatutTransactionWave;
|
||||
import dev.lions.unionflow.server.entity.mutuelle.ParametresFinanciersMutuelle;
|
||||
import dev.lions.unionflow.server.entity.mutuelle.epargne.CompteEpargne;
|
||||
import dev.lions.unionflow.server.entity.mutuelle.epargne.TransactionEpargne;
|
||||
import dev.lions.unionflow.server.entity.mutuelle.parts.ComptePartsSociales;
|
||||
import dev.lions.unionflow.server.entity.mutuelle.parts.TransactionPartsSociales;
|
||||
import dev.lions.unionflow.server.repository.mutuelle.ParametresFinanciersMutuellRepository;
|
||||
import dev.lions.unionflow.server.repository.mutuelle.epargne.CompteEpargneRepository;
|
||||
import dev.lions.unionflow.server.repository.mutuelle.epargne.TransactionEpargneRepository;
|
||||
import dev.lions.unionflow.server.repository.mutuelle.parts.ComptePartsSocialesRepository;
|
||||
import dev.lions.unionflow.server.repository.mutuelle.parts.TransactionPartsSocialesRepository;
|
||||
import io.quarkus.scheduler.Scheduled;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.transaction.Transactional;
|
||||
import org.jboss.logging.Logger;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Calcul automatique des intérêts sur épargne et des dividendes sur parts sociales.
|
||||
*
|
||||
* <p>Le scheduler tourne chaque jour à 02:00 et vérifie si un calcul est dû
|
||||
* selon la périodicité configurée par organisation.
|
||||
*/
|
||||
@ApplicationScoped
|
||||
public class InteretsEpargneService {
|
||||
|
||||
private static final Logger LOG = Logger.getLogger(InteretsEpargneService.class);
|
||||
|
||||
@Inject ParametresFinanciersMutuellRepository parametresRepo;
|
||||
@Inject CompteEpargneRepository compteEpargneRepository;
|
||||
@Inject TransactionEpargneRepository transactionEpargneRepository;
|
||||
@Inject ComptePartsSocialesRepository comptePartsSocialesRepository;
|
||||
@Inject TransactionPartsSocialesRepository transactionPartsSocialesRepository;
|
||||
|
||||
/**
|
||||
* Scheduler quotidien — calcule les intérêts pour toutes les organisations dont
|
||||
* la date prochaine_calcul_interets est aujourd'hui ou dans le passé.
|
||||
*/
|
||||
@Scheduled(cron = "0 0 2 * * ?")
|
||||
@Transactional
|
||||
public void calculerInteretsScheduled() {
|
||||
LocalDate aujourd_hui = LocalDate.now();
|
||||
List<ParametresFinanciersMutuelle> tous = parametresRepo.listAll();
|
||||
for (ParametresFinanciersMutuelle params : tous) {
|
||||
if (params.getProchaineCalculInterets() != null
|
||||
&& !params.getProchaineCalculInterets().isAfter(aujourd_hui)) {
|
||||
try {
|
||||
calculerInteretsPourOrg(params);
|
||||
} catch (Exception e) {
|
||||
LOG.errorf("Erreur calcul intérêts org %s: %s",
|
||||
params.getOrganisation().getId(), e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Déclenchement manuel par un admin pour une organisation donnée.
|
||||
* @return résumé : nombre de comptes traités, montant total crédité
|
||||
*/
|
||||
@Transactional
|
||||
public Map<String, Object> calculerManuellement(UUID orgId) {
|
||||
ParametresFinanciersMutuelle params = parametresRepo.findByOrganisation(orgId)
|
||||
.orElseThrow(() -> new IllegalArgumentException(
|
||||
"Aucun paramètre financier configuré pour cette organisation. "
|
||||
+ "Créez d'abord les paramètres via POST /api/v1/mutuelle/parametres-financiers."));
|
||||
|
||||
return calculerInteretsPourOrg(params);
|
||||
}
|
||||
|
||||
// ─── Internal ─────────────────────────────────────────────────────────────
|
||||
|
||||
Map<String, Object> calculerInteretsPourOrg(ParametresFinanciersMutuelle params) {
|
||||
UUID orgId = params.getOrganisation().getId();
|
||||
LOG.infof("Calcul intérêts org %s — taux épargne=%.4f, taux parts=%.4f",
|
||||
orgId, params.getTauxInteretAnnuelEpargne(), params.getTauxDividendePartsAnnuel());
|
||||
|
||||
int nbEpargne = calculerInteretsEpargne(params, orgId);
|
||||
int nbParts = calculerDividendesParts(params, orgId);
|
||||
|
||||
// Mise à jour des dates
|
||||
params.setDernierCalculInterets(LocalDate.now());
|
||||
params.setDernierNbComptesTraites(nbEpargne + nbParts);
|
||||
params.setProchaineCalculInterets(prochaineDateCalcul(params));
|
||||
|
||||
LOG.infof("Calcul terminé org %s — %d comptes épargne, %d comptes parts", orgId, nbEpargne, nbParts);
|
||||
return Map.of(
|
||||
"organisationId", orgId.toString(),
|
||||
"comptesEpargneTraites", nbEpargne,
|
||||
"comptesPartsTraites", nbParts,
|
||||
"dateCalcul", LocalDate.now().toString(),
|
||||
"prochaineCalcul", params.getProchaineCalculInterets().toString()
|
||||
);
|
||||
}
|
||||
|
||||
private int calculerInteretsEpargne(ParametresFinanciersMutuelle params, UUID orgId) {
|
||||
if (params.getTauxInteretAnnuelEpargne().compareTo(BigDecimal.ZERO) == 0) return 0;
|
||||
|
||||
List<CompteEpargne> comptes = compteEpargneRepository
|
||||
.find("organisation.id = ?1 AND statut = ?2 AND actif = true",
|
||||
orgId, StatutCompteEpargne.ACTIF)
|
||||
.list();
|
||||
|
||||
BigDecimal tauxPeriodique = calculerTauxPeriodique(
|
||||
params.getTauxInteretAnnuelEpargne(), params.getPeriodiciteCalcul());
|
||||
BigDecimal seuil = params.getSeuilMinEpargneInterets() != null
|
||||
? params.getSeuilMinEpargneInterets() : BigDecimal.ZERO;
|
||||
|
||||
int count = 0;
|
||||
for (CompteEpargne compte : comptes) {
|
||||
BigDecimal solde = compte.getSoldeActuel().subtract(compte.getSoldeBloque());
|
||||
if (solde.compareTo(seuil) <= 0) continue;
|
||||
|
||||
BigDecimal interets = solde.multiply(tauxPeriodique).setScale(0, RoundingMode.HALF_UP);
|
||||
if (interets.compareTo(BigDecimal.ZERO) <= 0) continue;
|
||||
|
||||
compte.setSoldeActuel(compte.getSoldeActuel().add(interets));
|
||||
compte.setDateDerniereTransaction(LocalDate.now());
|
||||
|
||||
TransactionEpargne tx = TransactionEpargne.builder()
|
||||
.compte(compte)
|
||||
.type(TypeTransactionEpargne.PAIEMENT_INTERETS)
|
||||
.montant(interets)
|
||||
.soldeAvant(solde)
|
||||
.soldeApres(compte.getSoldeActuel())
|
||||
.motif("Intérêts " + params.getPeriodiciteCalcul().toLowerCase()
|
||||
+ " — taux " + params.getTauxInteretAnnuelEpargne()
|
||||
.multiply(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP) + "%/an")
|
||||
.dateTransaction(LocalDateTime.now())
|
||||
.statutExecution(StatutTransactionWave.REUSSIE)
|
||||
.origineFonds("Calcul automatique intérêts")
|
||||
.build();
|
||||
transactionEpargneRepository.persist(tx);
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private int calculerDividendesParts(ParametresFinanciersMutuelle params, UUID orgId) {
|
||||
if (params.getTauxDividendePartsAnnuel().compareTo(BigDecimal.ZERO) == 0) return 0;
|
||||
|
||||
List<ComptePartsSociales> comptes = comptePartsSocialesRepository.findByOrganisation(orgId);
|
||||
BigDecimal tauxPeriodique = calculerTauxPeriodique(
|
||||
params.getTauxDividendePartsAnnuel(), params.getPeriodiciteCalcul());
|
||||
|
||||
int count = 0;
|
||||
for (ComptePartsSociales compte : comptes) {
|
||||
if (compte.getMontantTotal().compareTo(BigDecimal.ZERO) <= 0) continue;
|
||||
|
||||
BigDecimal dividende = compte.getMontantTotal()
|
||||
.multiply(tauxPeriodique).setScale(0, RoundingMode.HALF_UP);
|
||||
if (dividende.compareTo(BigDecimal.ZERO) <= 0) continue;
|
||||
|
||||
// Dividende: enregistré comme transaction (NB: ne modifie pas le nombre de parts)
|
||||
int partsRef = 1; // transaction symbolique — montant est le vrai indicateur
|
||||
TransactionPartsSociales tx = TransactionPartsSociales.builder()
|
||||
.compte(compte)
|
||||
.typeTransaction(TypeTransactionPartsSociales.PAIEMENT_DIVIDENDE)
|
||||
.nombreParts(partsRef)
|
||||
.montant(dividende)
|
||||
.soldePartsAvant(compte.getNombreParts())
|
||||
.soldePartsApres(compte.getNombreParts())
|
||||
.motif("Dividende " + params.getPeriodiciteCalcul().toLowerCase()
|
||||
+ " — taux " + params.getTauxDividendePartsAnnuel()
|
||||
.multiply(BigDecimal.valueOf(100)).setScale(2, RoundingMode.HALF_UP) + "%/an")
|
||||
.dateTransaction(LocalDateTime.now())
|
||||
.build();
|
||||
compte.setTotalDividendesRecus(compte.getTotalDividendesRecus().add(dividende));
|
||||
compte.setDateDerniereOperation(LocalDate.now());
|
||||
transactionPartsSocialesRepository.persist(tx);
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/** Taux périodique = taux annuel / nombre de périodes par an */
|
||||
private BigDecimal calculerTauxPeriodique(BigDecimal tauxAnnuel, String periodicite) {
|
||||
int diviseur = switch (periodicite.toUpperCase()) {
|
||||
case "MENSUEL" -> 12;
|
||||
case "TRIMESTRIEL" -> 4;
|
||||
default -> 1; // ANNUEL
|
||||
};
|
||||
return tauxAnnuel.divide(BigDecimal.valueOf(diviseur), 8, RoundingMode.HALF_UP);
|
||||
}
|
||||
|
||||
private LocalDate prochaineDateCalcul(ParametresFinanciersMutuelle params) {
|
||||
LocalDate base = LocalDate.now();
|
||||
return switch (params.getPeriodiciteCalcul().toUpperCase()) {
|
||||
case "MENSUEL" -> base.plusMonths(1);
|
||||
case "TRIMESTRIEL" -> base.plusMonths(3);
|
||||
default -> base.plusYears(1);
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package dev.lions.unionflow.server.service.mutuelle;
|
||||
|
||||
import dev.lions.unionflow.server.api.dto.mutuelle.financier.ParametresFinanciersMutuellRequest;
|
||||
import dev.lions.unionflow.server.api.dto.mutuelle.financier.ParametresFinanciersMutuellResponse;
|
||||
import dev.lions.unionflow.server.entity.Organisation;
|
||||
import dev.lions.unionflow.server.entity.mutuelle.ParametresFinanciersMutuelle;
|
||||
import dev.lions.unionflow.server.repository.OrganisationRepository;
|
||||
import dev.lions.unionflow.server.repository.mutuelle.ParametresFinanciersMutuellRepository;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.transaction.Transactional;
|
||||
import jakarta.ws.rs.NotFoundException;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.UUID;
|
||||
|
||||
@ApplicationScoped
|
||||
public class ParametresFinanciersService {
|
||||
|
||||
@Inject ParametresFinanciersMutuellRepository repo;
|
||||
@Inject OrganisationRepository organisationRepository;
|
||||
|
||||
public ParametresFinanciersMutuellResponse getByOrganisation(UUID orgId) {
|
||||
ParametresFinanciersMutuelle p = repo.findByOrganisation(orgId)
|
||||
.orElseThrow(() -> new NotFoundException("Aucun paramètre financier pour cette organisation."));
|
||||
return toDto(p);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public ParametresFinanciersMutuellResponse creerOuMettrAJour(ParametresFinanciersMutuellRequest req) {
|
||||
Organisation org = organisationRepository.findByIdOptional(UUID.fromString(req.getOrganisationId()))
|
||||
.orElseThrow(() -> new NotFoundException("Organisation introuvable: " + req.getOrganisationId()));
|
||||
|
||||
ParametresFinanciersMutuelle params = repo.findByOrganisation(org.getId())
|
||||
.orElseGet(() -> ParametresFinanciersMutuelle.builder().organisation(org).build());
|
||||
|
||||
params.setValeurNominaleParDefaut(req.getValeurNominaleParDefaut());
|
||||
params.setTauxInteretAnnuelEpargne(req.getTauxInteretAnnuelEpargne());
|
||||
params.setTauxDividendePartsAnnuel(req.getTauxDividendePartsAnnuel());
|
||||
params.setPeriodiciteCalcul(req.getPeriodiciteCalcul().toUpperCase());
|
||||
if (req.getSeuilMinEpargneInterets() != null) {
|
||||
params.setSeuilMinEpargneInterets(req.getSeuilMinEpargneInterets());
|
||||
}
|
||||
if (params.getProchaineCalculInterets() == null) {
|
||||
params.setProchaineCalculInterets(LocalDate.now().plusMonths(1));
|
||||
}
|
||||
|
||||
repo.persist(params);
|
||||
return toDto(params);
|
||||
}
|
||||
|
||||
private ParametresFinanciersMutuellResponse toDto(ParametresFinanciersMutuelle p) {
|
||||
return ParametresFinanciersMutuellResponse.builder()
|
||||
.organisationId(p.getOrganisation().getId().toString())
|
||||
.organisationNom(p.getOrganisation().getNom())
|
||||
.valeurNominaleParDefaut(p.getValeurNominaleParDefaut())
|
||||
.tauxInteretAnnuelEpargne(p.getTauxInteretAnnuelEpargne())
|
||||
.tauxDividendePartsAnnuel(p.getTauxDividendePartsAnnuel())
|
||||
.periodiciteCalcul(p.getPeriodiciteCalcul())
|
||||
.seuilMinEpargneInterets(p.getSeuilMinEpargneInterets())
|
||||
.prochaineCalculInterets(p.getProchaineCalculInterets())
|
||||
.dernierCalculInterets(p.getDernierCalculInterets())
|
||||
.dernierNbComptesTraites(p.getDernierNbComptesTraites())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
package dev.lions.unionflow.server.service.mutuelle;
|
||||
|
||||
import com.lowagie.text.*;
|
||||
import com.lowagie.text.Font;
|
||||
import com.lowagie.text.pdf.*;
|
||||
import dev.lions.unionflow.server.entity.mutuelle.epargne.CompteEpargne;
|
||||
import dev.lions.unionflow.server.entity.mutuelle.epargne.TransactionEpargne;
|
||||
import dev.lions.unionflow.server.entity.mutuelle.parts.ComptePartsSociales;
|
||||
import dev.lions.unionflow.server.entity.mutuelle.parts.TransactionPartsSociales;
|
||||
import dev.lions.unionflow.server.repository.mutuelle.epargne.CompteEpargneRepository;
|
||||
import dev.lions.unionflow.server.repository.mutuelle.epargne.TransactionEpargneRepository;
|
||||
import dev.lions.unionflow.server.repository.mutuelle.parts.ComptePartsSocialesRepository;
|
||||
import dev.lions.unionflow.server.repository.mutuelle.parts.TransactionPartsSocialesRepository;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.ws.rs.NotFoundException;
|
||||
|
||||
import java.awt.Color;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Génère les relevés de compte en PDF (OpenPDF).
|
||||
* Deux types : relevé épargne, relevé parts sociales.
|
||||
*/
|
||||
@ApplicationScoped
|
||||
public class ReleveComptePdfService {
|
||||
|
||||
private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("dd/MM/yyyy");
|
||||
private static final DateTimeFormatter DATETIME_FMT = DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm");
|
||||
private static final Color BLEU_UNIONFLOW = new Color(30, 90, 160);
|
||||
private static final Color GRIS_ENTETE = new Color(240, 240, 245);
|
||||
|
||||
@Inject CompteEpargneRepository compteEpargneRepository;
|
||||
@Inject TransactionEpargneRepository transactionEpargneRepository;
|
||||
@Inject ComptePartsSocialesRepository comptePartsSocialesRepository;
|
||||
@Inject TransactionPartsSocialesRepository transactionPartsSocialesRepository;
|
||||
|
||||
// ─── Relevé Épargne ────────────────────────────────────────────────────────
|
||||
|
||||
public byte[] genererReleveEpargne(UUID compteId, LocalDate dateDebut, LocalDate dateFin) {
|
||||
CompteEpargne compte = compteEpargneRepository.findByIdOptional(compteId)
|
||||
.orElseThrow(() -> new NotFoundException("Compte épargne introuvable: " + compteId));
|
||||
|
||||
List<TransactionEpargne> txs = transactionEpargneRepository
|
||||
.find("compte.id = ?1 ORDER BY dateTransaction ASC", compteId)
|
||||
.list();
|
||||
|
||||
if (dateDebut != null) {
|
||||
txs = txs.stream()
|
||||
.filter(t -> !t.getDateTransaction().toLocalDate().isBefore(dateDebut))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
if (dateFin != null) {
|
||||
txs = txs.stream()
|
||||
.filter(t -> !t.getDateTransaction().toLocalDate().isAfter(dateFin))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
return buildPdfEpargne(compte, txs, dateDebut, dateFin);
|
||||
}
|
||||
|
||||
// ─── Relevé Parts Sociales ─────────────────────────────────────────────────
|
||||
|
||||
public byte[] genererReleveParts(UUID compteId, LocalDate dateDebut, LocalDate dateFin) {
|
||||
ComptePartsSociales compte = comptePartsSocialesRepository.findByIdOptional(compteId)
|
||||
.orElseThrow(() -> new NotFoundException("Compte parts sociales introuvable: " + compteId));
|
||||
|
||||
List<TransactionPartsSociales> txs = transactionPartsSocialesRepository.findByCompte(compteId);
|
||||
// findByCompte is DESC — reverse for statement order
|
||||
java.util.Collections.reverse(txs);
|
||||
|
||||
if (dateDebut != null) {
|
||||
txs = txs.stream()
|
||||
.filter(t -> !t.getDateTransaction().toLocalDate().isBefore(dateDebut))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
if (dateFin != null) {
|
||||
txs = txs.stream()
|
||||
.filter(t -> !t.getDateTransaction().toLocalDate().isAfter(dateFin))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
return buildPdfParts(compte, txs, dateDebut, dateFin);
|
||||
}
|
||||
|
||||
// ─── PDF builders ──────────────────────────────────────────────────────────
|
||||
|
||||
private byte[] buildPdfEpargne(CompteEpargne compte, List<TransactionEpargne> txs,
|
||||
LocalDate dateDebut, LocalDate dateFin) {
|
||||
try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
|
||||
Document doc = new Document(PageSize.A4, 40, 40, 60, 40);
|
||||
PdfWriter.getInstance(doc, baos);
|
||||
doc.open();
|
||||
|
||||
addHeader(doc, compte.getOrganisation() != null ? compte.getOrganisation().getNom() : "UnionFlow",
|
||||
"RELEVÉ DE COMPTE ÉPARGNE");
|
||||
addInfoBlock(doc, new String[][]{
|
||||
{"Numéro de compte", compte.getNumeroCompte()},
|
||||
{"Type de compte", compte.getTypeCompte() != null ? compte.getTypeCompte().name() : ""},
|
||||
{"Titulaire", membreNom(compte)},
|
||||
{"Période", formatPeriode(dateDebut, dateFin)},
|
||||
{"Date d'édition", LocalDate.now().format(DATE_FMT)},
|
||||
{"Solde actuel", formatMontant(compte.getSoldeActuel())}
|
||||
});
|
||||
|
||||
// Solde d'ouverture de la période
|
||||
BigDecimal soldeOuverture = txs.isEmpty() ? compte.getSoldeActuel()
|
||||
: txs.get(0).getSoldeAvant();
|
||||
|
||||
PdfPTable table = createTable(new float[]{2f, 3f, 2.5f, 2.5f, 2.5f},
|
||||
new String[]{"Date", "Libellé", "Débit", "Crédit", "Solde"});
|
||||
|
||||
addLigneTotal(table, "Solde d'ouverture", null, null, soldeOuverture);
|
||||
|
||||
for (TransactionEpargne tx : txs) {
|
||||
boolean isDebit = isDebitEpargne(tx);
|
||||
table.addCell(cell(tx.getDateTransaction().format(DATE_FMT), false));
|
||||
table.addCell(cell(tx.getMotif() != null ? tx.getMotif() : tx.getType().name(), false));
|
||||
table.addCell(cellAmount(isDebit ? tx.getMontant() : null, true));
|
||||
table.addCell(cellAmount(!isDebit ? tx.getMontant() : null, false));
|
||||
table.addCell(cellAmount(tx.getSoldeApres(), false));
|
||||
}
|
||||
|
||||
doc.add(table);
|
||||
addSoldeFinal(doc, compte.getSoldeActuel());
|
||||
addFooter(doc);
|
||||
doc.close();
|
||||
return baos.toByteArray();
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Erreur génération relevé épargne PDF: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private byte[] buildPdfParts(ComptePartsSociales compte, List<TransactionPartsSociales> txs,
|
||||
LocalDate dateDebut, LocalDate dateFin) {
|
||||
try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
|
||||
Document doc = new Document(PageSize.A4, 40, 40, 60, 40);
|
||||
PdfWriter.getInstance(doc, baos);
|
||||
doc.open();
|
||||
|
||||
addHeader(doc, compte.getOrganisation() != null ? compte.getOrganisation().getNom() : "UnionFlow",
|
||||
"RELEVÉ DE PARTS SOCIALES");
|
||||
addInfoBlock(doc, new String[][]{
|
||||
{"Numéro de compte", compte.getNumeroCompte()},
|
||||
{"Titulaire", compte.getMembre() != null
|
||||
? compte.getMembre().getNom() + " " + compte.getMembre().getPrenom() : ""},
|
||||
{"Valeur nominale", formatMontant(compte.getValeurNominale()) + " / part"},
|
||||
{"Parts détenues", String.valueOf(compte.getNombreParts())},
|
||||
{"Capital total", formatMontant(compte.getMontantTotal())},
|
||||
{"Dividendes reçus", formatMontant(compte.getTotalDividendesRecus())},
|
||||
{"Période", formatPeriode(dateDebut, dateFin)},
|
||||
{"Date d'édition", LocalDate.now().format(DATE_FMT)}
|
||||
});
|
||||
|
||||
PdfPTable table = createTable(new float[]{2f, 3f, 2f, 2.5f, 2.5f, 2.5f},
|
||||
new String[]{"Date", "Libellé", "Parts", "Montant", "Avant", "Après"});
|
||||
|
||||
for (TransactionPartsSociales tx : txs) {
|
||||
table.addCell(cell(tx.getDateTransaction().format(DATE_FMT), false));
|
||||
table.addCell(cell(tx.getMotif() != null ? tx.getMotif() : tx.getTypeTransaction().getLibelle(), false));
|
||||
table.addCell(cell(String.valueOf(tx.getNombreParts()), false));
|
||||
table.addCell(cellAmount(tx.getMontant(), false));
|
||||
table.addCell(cell(String.valueOf(tx.getSoldePartsAvant()), false));
|
||||
table.addCell(cell(String.valueOf(tx.getSoldePartsApres()), false));
|
||||
}
|
||||
|
||||
doc.add(table);
|
||||
addFooter(doc);
|
||||
doc.close();
|
||||
return baos.toByteArray();
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Erreur génération relevé parts sociales PDF: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── PDF helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
private void addHeader(Document doc, String orgNom, String titre) throws DocumentException {
|
||||
Font fontOrg = FontFactory.getFont(FontFactory.HELVETICA_BOLD, 16, BLEU_UNIONFLOW);
|
||||
Font fontTitre = FontFactory.getFont(FontFactory.HELVETICA_BOLD, 13);
|
||||
Font fontSub = FontFactory.getFont(FontFactory.HELVETICA, 9, Color.GRAY);
|
||||
|
||||
Paragraph pOrg = new Paragraph(orgNom, fontOrg);
|
||||
pOrg.setAlignment(Element.ALIGN_CENTER);
|
||||
doc.add(pOrg);
|
||||
|
||||
Paragraph pTitre = new Paragraph(titre, fontTitre);
|
||||
pTitre.setAlignment(Element.ALIGN_CENTER);
|
||||
pTitre.setSpacingBefore(4);
|
||||
doc.add(pTitre);
|
||||
|
||||
Paragraph pSub = new Paragraph("Édité via UnionFlow · " + LocalDateTime.now().format(DATETIME_FMT), fontSub);
|
||||
pSub.setAlignment(Element.ALIGN_CENTER);
|
||||
pSub.setSpacingAfter(16);
|
||||
doc.add(pSub);
|
||||
|
||||
// Separator line
|
||||
PdfPTable sep = new PdfPTable(1);
|
||||
sep.setWidthPercentage(100);
|
||||
PdfPCell line = new PdfPCell(new Phrase(" "));
|
||||
line.setBorderColor(BLEU_UNIONFLOW);
|
||||
line.setBorderWidthBottom(1.5f);
|
||||
line.setBorderWidthTop(0);
|
||||
line.setBorderWidthLeft(0);
|
||||
line.setBorderWidthRight(0);
|
||||
line.setPaddingBottom(4);
|
||||
sep.addCell(line);
|
||||
doc.add(sep);
|
||||
doc.add(Chunk.NEWLINE);
|
||||
}
|
||||
|
||||
private void addInfoBlock(Document doc, String[][] lignes) throws DocumentException {
|
||||
PdfPTable t = new PdfPTable(2);
|
||||
t.setWidthPercentage(60);
|
||||
t.setHorizontalAlignment(Element.ALIGN_LEFT);
|
||||
t.setWidths(new float[]{2f, 3f});
|
||||
Font fontLabel = FontFactory.getFont(FontFactory.HELVETICA_BOLD, 9);
|
||||
Font fontVal = FontFactory.getFont(FontFactory.HELVETICA, 9);
|
||||
for (String[] row : lignes) {
|
||||
PdfPCell cLabel = new PdfPCell(new Phrase(row[0], fontLabel));
|
||||
cLabel.setBorder(Rectangle.NO_BORDER);
|
||||
cLabel.setPadding(3);
|
||||
PdfPCell cVal = new PdfPCell(new Phrase(row[1], fontVal));
|
||||
cVal.setBorder(Rectangle.NO_BORDER);
|
||||
cVal.setPadding(3);
|
||||
t.addCell(cLabel);
|
||||
t.addCell(cVal);
|
||||
}
|
||||
t.setSpacingAfter(12);
|
||||
doc.add(t);
|
||||
}
|
||||
|
||||
private PdfPTable createTable(float[] widths, String[] headers) throws DocumentException {
|
||||
PdfPTable table = new PdfPTable(widths.length);
|
||||
table.setWidthPercentage(100);
|
||||
table.setWidths(widths);
|
||||
Font fontHeader = FontFactory.getFont(FontFactory.HELVETICA_BOLD, 9, Color.WHITE);
|
||||
for (String h : headers) {
|
||||
PdfPCell cell = new PdfPCell(new Phrase(h, fontHeader));
|
||||
cell.setBackgroundColor(BLEU_UNIONFLOW);
|
||||
cell.setPadding(5);
|
||||
cell.setHorizontalAlignment(Element.ALIGN_CENTER);
|
||||
table.addCell(cell);
|
||||
}
|
||||
table.setHeaderRows(1);
|
||||
return table;
|
||||
}
|
||||
|
||||
private PdfPCell cell(String text, boolean bold) {
|
||||
Font f = bold
|
||||
? FontFactory.getFont(FontFactory.HELVETICA_BOLD, 8)
|
||||
: FontFactory.getFont(FontFactory.HELVETICA, 8);
|
||||
PdfPCell c = new PdfPCell(new Phrase(text != null ? text : "", f));
|
||||
c.setPadding(4);
|
||||
c.setBorderColor(Color.LIGHT_GRAY);
|
||||
return c;
|
||||
}
|
||||
|
||||
private PdfPCell cellAmount(BigDecimal amount, boolean isDebit) {
|
||||
if (amount == null || amount.compareTo(BigDecimal.ZERO) == 0) {
|
||||
PdfPCell c = cell("", false);
|
||||
c.setHorizontalAlignment(Element.ALIGN_RIGHT);
|
||||
return c;
|
||||
}
|
||||
Font f = FontFactory.getFont(FontFactory.HELVETICA, 8,
|
||||
isDebit ? new Color(180, 0, 0) : new Color(0, 130, 0));
|
||||
PdfPCell c = new PdfPCell(new Phrase(formatMontant(amount), f));
|
||||
c.setPadding(4);
|
||||
c.setHorizontalAlignment(Element.ALIGN_RIGHT);
|
||||
c.setBorderColor(Color.LIGHT_GRAY);
|
||||
return c;
|
||||
}
|
||||
|
||||
private void addLigneTotal(PdfPTable table, String label, BigDecimal debit,
|
||||
BigDecimal credit, BigDecimal solde) {
|
||||
Font f = FontFactory.getFont(FontFactory.HELVETICA_BOLD, 8);
|
||||
PdfPCell cLabel = new PdfPCell(new Phrase(label, f));
|
||||
cLabel.setColspan(2);
|
||||
cLabel.setBackgroundColor(GRIS_ENTETE);
|
||||
cLabel.setPadding(4);
|
||||
table.addCell(cLabel);
|
||||
table.addCell(cellAmount(debit, true));
|
||||
table.addCell(cellAmount(credit, false));
|
||||
PdfPCell cSolde = cellAmount(solde, false);
|
||||
cSolde.setBackgroundColor(GRIS_ENTETE);
|
||||
table.addCell(cSolde);
|
||||
}
|
||||
|
||||
private void addSoldeFinal(Document doc, BigDecimal solde) throws DocumentException {
|
||||
Font f = FontFactory.getFont(FontFactory.HELVETICA_BOLD, 10, BLEU_UNIONFLOW);
|
||||
Paragraph p = new Paragraph("Solde final : " + formatMontant(solde), f);
|
||||
p.setAlignment(Element.ALIGN_RIGHT);
|
||||
p.setSpacingBefore(8);
|
||||
doc.add(p);
|
||||
}
|
||||
|
||||
private void addFooter(Document doc) throws DocumentException {
|
||||
Font f = FontFactory.getFont(FontFactory.HELVETICA, 8, Color.GRAY);
|
||||
Paragraph p = new Paragraph(
|
||||
"Document généré automatiquement par UnionFlow — confidentiel.", f);
|
||||
p.setAlignment(Element.ALIGN_CENTER);
|
||||
p.setSpacingBefore(20);
|
||||
doc.add(p);
|
||||
}
|
||||
|
||||
private String formatMontant(BigDecimal m) {
|
||||
if (m == null) return "0 XOF";
|
||||
return String.format("%,.0f XOF", m.doubleValue());
|
||||
}
|
||||
|
||||
private String formatPeriode(LocalDate debut, LocalDate fin) {
|
||||
if (debut == null && fin == null) return "Toutes opérations";
|
||||
if (debut == null) return "jusqu'au " + fin.format(DATE_FMT);
|
||||
if (fin == null) return "depuis le " + debut.format(DATE_FMT);
|
||||
return debut.format(DATE_FMT) + " au " + fin.format(DATE_FMT);
|
||||
}
|
||||
|
||||
private String membreNom(CompteEpargne compte) {
|
||||
if (compte.getMembre() == null) return "";
|
||||
return compte.getMembre().getNom() + " " + compte.getMembre().getPrenom();
|
||||
}
|
||||
|
||||
private boolean isDebitEpargne(TransactionEpargne tx) {
|
||||
return switch (tx.getType()) {
|
||||
case RETRAIT, PRELEVEMENT_FRAIS, TRANSFERT_SORTANT, REMBOURSEMENT_CREDIT, RETENUE_GARANTIE -> true;
|
||||
default -> false;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package dev.lions.unionflow.server.service.mutuelle.parts;
|
||||
|
||||
import dev.lions.unionflow.server.api.dto.mutuelle.parts.ComptePartsSocialesRequest;
|
||||
import dev.lions.unionflow.server.api.dto.mutuelle.parts.ComptePartsSocialesResponse;
|
||||
import dev.lions.unionflow.server.api.dto.mutuelle.parts.TransactionPartsSocialesRequest;
|
||||
import dev.lions.unionflow.server.api.dto.mutuelle.parts.TransactionPartsSocialesResponse;
|
||||
import dev.lions.unionflow.server.api.enums.mutuelle.parts.StatutComptePartsSociales;
|
||||
import dev.lions.unionflow.server.api.enums.mutuelle.parts.TypeTransactionPartsSociales;
|
||||
import dev.lions.unionflow.server.entity.Membre;
|
||||
import dev.lions.unionflow.server.entity.Organisation;
|
||||
import dev.lions.unionflow.server.entity.mutuelle.ParametresFinanciersMutuelle;
|
||||
import dev.lions.unionflow.server.entity.mutuelle.parts.ComptePartsSociales;
|
||||
import dev.lions.unionflow.server.entity.mutuelle.parts.TransactionPartsSociales;
|
||||
import dev.lions.unionflow.server.mapper.mutuelle.parts.ComptePartsSocialesMapper;
|
||||
import dev.lions.unionflow.server.mapper.mutuelle.parts.TransactionPartsSocialesMapper;
|
||||
import dev.lions.unionflow.server.repository.MembreRepository;
|
||||
import dev.lions.unionflow.server.repository.OrganisationRepository;
|
||||
import dev.lions.unionflow.server.repository.mutuelle.ParametresFinanciersMutuellRepository;
|
||||
import dev.lions.unionflow.server.repository.mutuelle.parts.ComptePartsSocialesRepository;
|
||||
import dev.lions.unionflow.server.repository.mutuelle.parts.TransactionPartsSocialesRepository;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.transaction.Transactional;
|
||||
import jakarta.ws.rs.NotFoundException;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@ApplicationScoped
|
||||
public class ComptePartsSocialesService {
|
||||
|
||||
private static final BigDecimal VALEUR_NOMINALE_DEFAUT = new BigDecimal("5000");
|
||||
|
||||
@Inject ComptePartsSocialesRepository compteRepo;
|
||||
@Inject TransactionPartsSocialesRepository txRepo;
|
||||
@Inject MembreRepository membreRepository;
|
||||
@Inject OrganisationRepository organisationRepository;
|
||||
@Inject ParametresFinanciersMutuellRepository parametresRepo;
|
||||
@Inject ComptePartsSocialesMapper compteMapper;
|
||||
@Inject TransactionPartsSocialesMapper txMapper;
|
||||
|
||||
@Transactional
|
||||
public ComptePartsSocialesResponse ouvrirCompte(ComptePartsSocialesRequest req) {
|
||||
Membre membre = membreRepository.findByIdOptional(UUID.fromString(req.getMembreId()))
|
||||
.orElseThrow(() -> new NotFoundException("Membre introuvable: " + req.getMembreId()));
|
||||
Organisation org = organisationRepository.findByIdOptional(UUID.fromString(req.getOrganisationId()))
|
||||
.orElseThrow(() -> new NotFoundException("Organisation introuvable: " + req.getOrganisationId()));
|
||||
|
||||
// Reject duplicate (one compte per member per org)
|
||||
compteRepo.findByMembreAndOrg(membre.getId(), org.getId()).ifPresent(c -> {
|
||||
throw new IllegalStateException("Un compte de parts sociales existe déjà pour ce membre dans cette organisation.");
|
||||
});
|
||||
|
||||
BigDecimal valeurNominale = resolveValeurNominale(req.getValeurNominale(), org.getId());
|
||||
|
||||
ComptePartsSociales compte = ComptePartsSociales.builder()
|
||||
.membre(membre)
|
||||
.organisation(org)
|
||||
.numeroCompte(genererNumeroCompte(org))
|
||||
.nombreParts(0)
|
||||
.valeurNominale(valeurNominale)
|
||||
.montantTotal(BigDecimal.ZERO)
|
||||
.totalDividendesRecus(BigDecimal.ZERO)
|
||||
.statut(StatutComptePartsSociales.ACTIF)
|
||||
.dateOuverture(LocalDate.now())
|
||||
.notes(req.getNotes())
|
||||
.build();
|
||||
compteRepo.persist(compte);
|
||||
|
||||
// Initial souscription si nombreParts > 0
|
||||
if (req.getNombreParts() != null && req.getNombreParts() > 0) {
|
||||
enregistrerTransaction(compte, TypeTransactionPartsSociales.SOUSCRIPTION,
|
||||
req.getNombreParts(), null, "Souscription initiale à l'ouverture", null);
|
||||
}
|
||||
|
||||
return compteMapper.toDto(compte);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public TransactionPartsSocialesResponse enregistrerSouscription(TransactionPartsSocialesRequest req) {
|
||||
ComptePartsSociales compte = findCompteActif(req.getCompteId());
|
||||
return txMapper.toDto(
|
||||
enregistrerTransaction(compte, req.getTypeTransaction(),
|
||||
req.getNombreParts(), req.getMontant(), req.getMotif(), req.getReferenceExterne()));
|
||||
}
|
||||
|
||||
public ComptePartsSocialesResponse getById(UUID id) {
|
||||
return compteMapper.toDto(compteRepo.findByIdOptional(id)
|
||||
.orElseThrow(() -> new NotFoundException("Compte parts sociales introuvable: " + id)));
|
||||
}
|
||||
|
||||
public List<ComptePartsSocialesResponse> getByMembre(UUID membreId) {
|
||||
return compteRepo.findByMembre(membreId).stream().map(compteMapper::toDto).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
public List<ComptePartsSocialesResponse> getByOrganisation(UUID orgId) {
|
||||
return compteRepo.findByOrganisation(orgId).stream().map(compteMapper::toDto).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
public List<TransactionPartsSocialesResponse> getTransactions(UUID compteId) {
|
||||
return txRepo.findByCompte(compteId).stream().map(txMapper::toDto).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
// ─── Internal helpers ──────────────────────────────────────────────────────
|
||||
|
||||
TransactionPartsSociales enregistrerTransaction(
|
||||
ComptePartsSociales compte,
|
||||
TypeTransactionPartsSociales type,
|
||||
int nombreParts,
|
||||
BigDecimal montantOverride,
|
||||
String motif,
|
||||
String referenceExterne) {
|
||||
|
||||
if (compte.getStatut() != StatutComptePartsSociales.ACTIF) {
|
||||
throw new IllegalArgumentException("Impossible d'effectuer une opération sur un compte non actif.");
|
||||
}
|
||||
|
||||
int soldeAvant = compte.getNombreParts();
|
||||
int soldeApres;
|
||||
BigDecimal montant = montantOverride != null
|
||||
? montantOverride
|
||||
: compte.getValeurNominale().multiply(BigDecimal.valueOf(nombreParts));
|
||||
|
||||
switch (type) {
|
||||
case SOUSCRIPTION, SOUSCRIPTION_IMPORT, PAIEMENT_DIVIDENDE -> {
|
||||
soldeApres = soldeAvant + nombreParts;
|
||||
compte.setNombreParts(soldeApres);
|
||||
compte.setMontantTotal(compte.getValeurNominale().multiply(BigDecimal.valueOf(soldeApres)));
|
||||
if (type == TypeTransactionPartsSociales.PAIEMENT_DIVIDENDE) {
|
||||
compte.setTotalDividendesRecus(compte.getTotalDividendesRecus().add(montant));
|
||||
}
|
||||
}
|
||||
case CESSION_PARTIELLE -> {
|
||||
if (nombreParts > soldeAvant) {
|
||||
throw new IllegalArgumentException(
|
||||
"Nombre de parts à céder (" + nombreParts + ") supérieur au solde (" + soldeAvant + ").");
|
||||
}
|
||||
soldeApres = soldeAvant - nombreParts;
|
||||
compte.setNombreParts(soldeApres);
|
||||
compte.setMontantTotal(compte.getValeurNominale().multiply(BigDecimal.valueOf(soldeApres)));
|
||||
}
|
||||
case RACHAT_TOTAL -> {
|
||||
soldeApres = 0;
|
||||
compte.setNombreParts(0);
|
||||
compte.setMontantTotal(BigDecimal.ZERO);
|
||||
compte.setStatut(StatutComptePartsSociales.CLOS);
|
||||
}
|
||||
case CORRECTION -> {
|
||||
// Admin correction: use nombreParts as the new absolute balance
|
||||
soldeApres = nombreParts;
|
||||
compte.setNombreParts(soldeApres);
|
||||
compte.setMontantTotal(compte.getValeurNominale().multiply(BigDecimal.valueOf(soldeApres)));
|
||||
}
|
||||
default -> throw new IllegalArgumentException("Type de transaction non pris en charge: " + type);
|
||||
}
|
||||
|
||||
compte.setDateDerniereOperation(LocalDate.now());
|
||||
|
||||
TransactionPartsSociales tx = TransactionPartsSociales.builder()
|
||||
.compte(compte)
|
||||
.typeTransaction(type)
|
||||
.nombreParts(nombreParts)
|
||||
.montant(montant)
|
||||
.soldePartsAvant(soldeAvant)
|
||||
.soldePartsApres(soldeApres)
|
||||
.motif(motif)
|
||||
.referenceExterne(referenceExterne)
|
||||
.dateTransaction(LocalDateTime.now())
|
||||
.build();
|
||||
txRepo.persist(tx);
|
||||
return tx;
|
||||
}
|
||||
|
||||
private ComptePartsSociales findCompteActif(String compteId) {
|
||||
return compteRepo.findByIdOptional(UUID.fromString(compteId))
|
||||
.orElseThrow(() -> new NotFoundException("Compte parts sociales introuvable: " + compteId));
|
||||
}
|
||||
|
||||
private BigDecimal resolveValeurNominale(BigDecimal fromRequest, UUID orgId) {
|
||||
if (fromRequest != null && fromRequest.compareTo(BigDecimal.ZERO) > 0) {
|
||||
return fromRequest;
|
||||
}
|
||||
return parametresRepo.findByOrganisation(orgId)
|
||||
.map(ParametresFinanciersMutuelle::getValeurNominaleParDefaut)
|
||||
.orElse(VALEUR_NOMINALE_DEFAUT);
|
||||
}
|
||||
|
||||
private static final AtomicInteger COUNTER = new AtomicInteger(0);
|
||||
|
||||
private String genererNumeroCompte(Organisation org) {
|
||||
String prefix = "PS-" + (org.getNomCourt() != null ? org.getNomCourt().toUpperCase() : "ORG") + "-";
|
||||
long count = compteRepo.count("organisation.id", org.getId());
|
||||
return prefix + String.format("%05d", count + 1);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user