fix(disaster-recovery 2/2): restaurer 242 fichiers Java modifiés par a72ab54
Some checks failed
CI/CD Pipeline / pipeline (push) Failing after 3m22s
Some checks failed
CI/CD Pipeline / pipeline (push) Failing after 3m22s
Suite à la récupération précédente (044ca4b) qui n'avait restauré que les fichiers SUPPRIMÉS, ce commit restaure les MODIFICATIONS d'entités/services qui étaient nécessaires pour que les fichiers restaurés compilent. Restaurés depuis a72ab54^ (=31330d9+ corrections) : - Entities : Organisation, FormuleAbonnement, AuditService, MembreOrganisation, SouscriptionOrganisation, etc. - Services : MigrerOrganisationsVersKeycloakService, ComptabilitePdfService, KycAmlService, AuditService.logKycRisqueEleve, etc. - Resources : PaiementUnifieResource, etc. Backend compile désormais (BUILD SUCCESS).
This commit is contained in:
@@ -53,8 +53,8 @@ public class CompteComptable extends BaseEntity {
|
||||
|
||||
/** Classe comptable (1-7) */
|
||||
@NotNull
|
||||
@Min(value = 1, message = "La classe comptable doit être entre 1 et 7")
|
||||
@Max(value = 7, message = "La classe comptable doit être entre 1 et 7")
|
||||
@Min(value = 1, message = "La classe comptable doit être entre 1 et 9")
|
||||
@Max(value = 9, message = "La classe comptable doit être entre 1 et 9")
|
||||
@Column(name = "classe_comptable", nullable = false)
|
||||
private Integer classeComptable;
|
||||
|
||||
@@ -85,6 +85,11 @@ public class CompteComptable extends BaseEntity {
|
||||
@Column(name = "description", length = 500)
|
||||
private String description;
|
||||
|
||||
/** Organisation propriétaire (null = compte standard global) */
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "organisation_id")
|
||||
private Organisation organisation;
|
||||
|
||||
/** Lignes d'écriture associées */
|
||||
@JsonIgnore
|
||||
@OneToMany(mappedBy = "compteComptable", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
|
||||
|
||||
@@ -110,6 +110,10 @@ public class FormuleAbonnement extends BaseEntity {
|
||||
@Column(name = "max_admins")
|
||||
private Integer maxAdmins;
|
||||
|
||||
/** Code du provider de paiement par défaut (WAVE, ORANGE_MONEY, MTN_MOMO, PISPI). NULL = global. */
|
||||
@Column(name = "provider_defaut", length = 20)
|
||||
private String providerDefaut;
|
||||
|
||||
public boolean isIllimitee() {
|
||||
return maxMembres == null;
|
||||
}
|
||||
|
||||
@@ -24,8 +24,11 @@ import lombok.NoArgsConstructor;
|
||||
@Entity
|
||||
@Table(
|
||||
name = "journaux_comptables",
|
||||
uniqueConstraints = {
|
||||
@UniqueConstraint(name = "uk_journaux_org_code", columnNames = {"organisation_id", "code"})
|
||||
},
|
||||
indexes = {
|
||||
@Index(name = "idx_journal_code", columnList = "code", unique = true),
|
||||
@Index(name = "idx_journal_code", columnList = "code"),
|
||||
@Index(name = "idx_journal_type", columnList = "type_journal"),
|
||||
@Index(name = "idx_journal_periode", columnList = "date_debut, date_fin")
|
||||
})
|
||||
@@ -36,9 +39,9 @@ import lombok.NoArgsConstructor;
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class JournalComptable extends BaseEntity {
|
||||
|
||||
/** Code unique du journal */
|
||||
/** Code du journal (unique par organisation). */
|
||||
@NotBlank
|
||||
@Column(name = "code", unique = true, nullable = false, length = 10)
|
||||
@Column(name = "code", nullable = false, length = 10)
|
||||
private String code;
|
||||
|
||||
/** Libellé du journal */
|
||||
@@ -69,6 +72,11 @@ public class JournalComptable extends BaseEntity {
|
||||
@Column(name = "description", length = 500)
|
||||
private String description;
|
||||
|
||||
/** Organisation propriétaire */
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "organisation_id")
|
||||
private Organisation organisation;
|
||||
|
||||
/** Écritures comptables associées */
|
||||
@JsonIgnore
|
||||
@OneToMany(mappedBy = "journal", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
|
||||
|
||||
@@ -59,6 +59,10 @@ public class Membre extends BaseEntity {
|
||||
@Column(name = "telephone", length = 20)
|
||||
private String telephone;
|
||||
|
||||
/** Token FCM pour les notifications push Firebase. NULL si l'app mobile n'est pas installée ou si le membre a refusé les notifications. */
|
||||
@Column(name = "fcm_token", length = 500)
|
||||
private String fcmToken;
|
||||
|
||||
@Pattern(regexp = "^\\+[1-9][0-9]{6,14}$", message = "Le numéro Wave doit être au format international E.164 (ex: +22507XXXXXXXX)")
|
||||
@Column(name = "telephone_wave", length = 20)
|
||||
private String telephoneWave;
|
||||
|
||||
@@ -8,6 +8,7 @@ import java.time.LocalDate;
|
||||
import java.time.Period;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
@@ -201,6 +202,10 @@ public class Organisation extends BaseEntity {
|
||||
@Column(name = "categorie_type", length = 50)
|
||||
private String categorieType;
|
||||
|
||||
/** ID de l'Organization Keycloak 26 correspondante — null si pas encore migrée. */
|
||||
@Column(name = "keycloak_org_id")
|
||||
private UUID keycloakOrgId;
|
||||
|
||||
/** Modules activés pour cette organisation (liste CSV, ex: "MEMBRES,COTISATIONS,TONTINE") */
|
||||
@Column(name = "modules_actifs", length = 1000)
|
||||
private String modulesActifs;
|
||||
|
||||
@@ -98,7 +98,9 @@ public class GlobalExceptionMapper implements ExceptionMapper<Throwable> {
|
||||
return exception instanceof NotFoundException
|
||||
|| exception instanceof ForbiddenException
|
||||
|| exception instanceof NotAuthorizedException
|
||||
|| exception instanceof NotAllowedException;
|
||||
|| exception instanceof NotAllowedException
|
||||
|| exception instanceof IllegalArgumentException
|
||||
|| exception instanceof IllegalStateException;
|
||||
}
|
||||
|
||||
private int determineStatusCode(Throwable exception) {
|
||||
|
||||
@@ -76,6 +76,30 @@ public class CompteComptableRepository implements PanacheRepositoryBase<CompteCo
|
||||
return find("typeCompte = ?1 AND actif = true ORDER BY numeroCompte ASC", TypeCompteComptable.TRESORERIE)
|
||||
.list();
|
||||
}
|
||||
|
||||
/**
|
||||
* Trouve un compte par organisation et numéro de compte (plan comptable tenant-scoped).
|
||||
*/
|
||||
public Optional<CompteComptable> findByOrganisationAndNumero(UUID organisationId, String numeroCompte) {
|
||||
return find("organisation.id = ?1 AND numeroCompte = ?2 AND actif = true", organisationId, numeroCompte)
|
||||
.firstResultOptional();
|
||||
}
|
||||
|
||||
/**
|
||||
* Trouve tous les comptes actifs d'une organisation.
|
||||
*/
|
||||
public List<CompteComptable> findByOrganisation(UUID organisationId) {
|
||||
return find("organisation.id = ?1 AND actif = true ORDER BY numeroCompte ASC", organisationId).list();
|
||||
}
|
||||
|
||||
/**
|
||||
* Trouve les comptes d'une organisation par classe SYSCOHADA (1-9).
|
||||
*/
|
||||
public List<CompteComptable> findByOrganisationAndClasse(UUID organisationId, Integer classe) {
|
||||
return find(
|
||||
"organisation.id = ?1 AND classeComptable = ?2 AND actif = true ORDER BY numeroCompte ASC",
|
||||
organisationId, classe).list();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -105,6 +105,20 @@ public class EcritureComptableRepository implements PanacheRepositoryBase<Ecritu
|
||||
public List<EcritureComptable> findByLettrage(String lettrage) {
|
||||
return find("lettrage = ?1 AND actif = true ORDER BY dateEcriture DESC", lettrage).list();
|
||||
}
|
||||
|
||||
/**
|
||||
* Trouve les écritures d'une organisation dans une période (pour rapports PDF SYSCOHADA).
|
||||
*/
|
||||
public List<EcritureComptable> findByOrganisationAndDateRange(
|
||||
UUID organisationId, LocalDate dateDebut, LocalDate dateFin) {
|
||||
return find(
|
||||
"organisation.id = ?1 AND dateEcriture >= ?2 AND dateEcriture <= ?3 AND actif = true"
|
||||
+ " ORDER BY dateEcriture ASC, numeroPiece ASC",
|
||||
organisationId,
|
||||
dateDebut,
|
||||
dateFin)
|
||||
.list();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -79,6 +79,15 @@ public class JournalComptableRepository implements PanacheRepositoryBase<Journal
|
||||
public List<JournalComptable> findAllActifs() {
|
||||
return find("actif = true ORDER BY code ASC").list();
|
||||
}
|
||||
|
||||
/**
|
||||
* Trouve le journal d'une organisation par type (ex: VENTES pour cotisations).
|
||||
*/
|
||||
public Optional<JournalComptable> findByOrganisationAndType(UUID organisationId, TypeJournalComptable type) {
|
||||
return find(
|
||||
"organisation.id = ?1 AND typeJournal = ?2 AND statut = 'OUVERT' AND actif = true",
|
||||
organisationId, type).firstResultOptional();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -85,6 +85,13 @@ public class MembreOrganisationRepository extends BaseRepository<MembreOrganisat
|
||||
return find("membre.email = ?1 and organisation.id = ?2", email, organisationId).firstResultOptional();
|
||||
}
|
||||
|
||||
/**
|
||||
* Trouve les membres ayant un rôle donné dans une organisation.
|
||||
*/
|
||||
public List<MembreOrganisation> findByRoleOrgAndOrganisationId(String roleOrg, UUID organisationId) {
|
||||
return find("roleOrg = ?1 and organisation.id = ?2 and membre.actif = true", roleOrg, organisationId).list();
|
||||
}
|
||||
|
||||
/**
|
||||
* Trouve les membres en attente de validation depuis plus de N jours.
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package dev.lions.unionflow.server.resource;
|
||||
|
||||
import dev.lions.unionflow.server.api.dto.membre.CompteAdherentResponse;
|
||||
import dev.lions.unionflow.server.service.FirebasePushService;
|
||||
import dev.lions.unionflow.server.entity.Membre;
|
||||
import dev.lions.unionflow.server.entity.MembreOrganisation;
|
||||
import dev.lions.unionflow.server.entity.SouscriptionOrganisation;
|
||||
@@ -67,6 +68,59 @@ public class CompteAdherentResource {
|
||||
@Inject
|
||||
MembreService membreService;
|
||||
|
||||
@Inject
|
||||
FirebasePushService firebasePushService;
|
||||
|
||||
/**
|
||||
* Enregistre ou met à jour le token FCM du membre connecté pour les notifications push.
|
||||
* Appelé par l'application mobile au démarrage ou quand Firebase renouvelle le token.
|
||||
*/
|
||||
@PUT
|
||||
@Path("/mon-compte/fcm-token")
|
||||
@Authenticated
|
||||
@Operation(summary = "Enregistrer le token FCM pour les notifications push")
|
||||
@jakarta.transaction.Transactional
|
||||
public Response enregistrerFcmToken(Map<String, String> body) {
|
||||
String email = securiteHelper.resolveEmail();
|
||||
if (email == null) return Response.status(Response.Status.UNAUTHORIZED).build();
|
||||
|
||||
String token = body != null ? body.get("token") : null;
|
||||
if (token == null || token.isBlank()) {
|
||||
return Response.status(Response.Status.BAD_REQUEST)
|
||||
.entity(Map.of("message", "Le champ 'token' est requis.")).build();
|
||||
}
|
||||
|
||||
return membreRepository.findByEmail(email)
|
||||
.map(membre -> {
|
||||
membre.setFcmToken(token.trim());
|
||||
membreRepository.persist(membre);
|
||||
return Response.ok(Map.of("message", "Token FCM enregistré.")).build();
|
||||
})
|
||||
.orElse(Response.status(Response.Status.NOT_FOUND)
|
||||
.entity(Map.of("message", "Membre introuvable.")).build());
|
||||
}
|
||||
|
||||
/**
|
||||
* Supprime le token FCM (désabonnement des notifications push).
|
||||
*/
|
||||
@DELETE
|
||||
@Path("/mon-compte/fcm-token")
|
||||
@Authenticated
|
||||
@Operation(summary = "Désactiver les notifications push")
|
||||
@jakarta.transaction.Transactional
|
||||
public Response supprimerFcmToken() {
|
||||
String email = securiteHelper.resolveEmail();
|
||||
if (email == null) return Response.status(Response.Status.UNAUTHORIZED).build();
|
||||
|
||||
return membreRepository.findByEmail(email)
|
||||
.map(membre -> {
|
||||
membre.setFcmToken(null);
|
||||
membreRepository.persist(membre);
|
||||
return Response.ok(Map.of("message", "Notifications push désactivées.")).build();
|
||||
})
|
||||
.orElse(Response.status(Response.Status.NOT_FOUND).build());
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne le compte adhérent complet du membre connecté :
|
||||
* numéro de membre, soldes (cotisations + épargne), capacité d'emprunt, taux d'engagement.
|
||||
@@ -138,15 +192,17 @@ public class CompteAdherentResource {
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback : auto-activer si EN_ATTENTE_VALIDATION et org a souscription active
|
||||
// (membres sans premiereConnexion=true ou créés avant cette logique)
|
||||
// Fallback : auto-activer si EN_ATTENTE_VALIDATION et org a reçu un paiement.
|
||||
// Couvre le cas PAIEMENT_CONFIRME (admin a payé mais super admin n'a pas encore validé)
|
||||
// et ACTIVE/VALIDEE (chemin nominal). L'admin ne doit pas bloquer sur l'AwaitingValidationPage
|
||||
// dès lors que le paiement est confirmé côté Wave.
|
||||
if ("EN_ATTENTE_VALIDATION".equals(statutCompte) && membreOpt.isPresent()) {
|
||||
Membre m = membreOpt.get();
|
||||
UUID orgId = membreOrganisationRepo.findFirstByMembreId(m.getId())
|
||||
.map(mo -> mo.getOrganisation().getId())
|
||||
.orElse(null);
|
||||
if (membreService.orgHasActiveSubscription(orgId)) {
|
||||
LOG.infof("Auto-activation au login de %s (org %s a souscription active)", m.getEmail(), orgId);
|
||||
if (membreService.orgHasPaidSubscription(orgId)) {
|
||||
LOG.infof("Auto-activation au login de %s (org %s a souscription payée)", m.getEmail(), orgId);
|
||||
membreService.activerMembre(m.getId());
|
||||
try {
|
||||
membreKeycloakSyncService.activerMembreDansKeycloak(m.getId());
|
||||
|
||||
@@ -11,6 +11,7 @@ import dev.lions.unionflow.server.entity.Membre;
|
||||
import dev.lions.unionflow.server.entity.Organisation;
|
||||
import dev.lions.unionflow.server.entity.MembreOrganisation;
|
||||
import dev.lions.unionflow.server.repository.MembreOrganisationRepository;
|
||||
import dev.lions.unionflow.server.repository.MembreRepository;
|
||||
import dev.lions.unionflow.server.repository.MembreRoleRepository;
|
||||
import dev.lions.unionflow.server.service.MemberLifecycleService;
|
||||
import dev.lions.unionflow.server.service.MembreKeycloakSyncService;
|
||||
@@ -78,6 +79,9 @@ public class MembreResource {
|
||||
@Inject
|
||||
MembreOrganisationRepository membreOrgRepository;
|
||||
|
||||
@Inject
|
||||
MembreRepository membreRepository;
|
||||
|
||||
@Inject
|
||||
MembreRoleRepository membreRoleRepository;
|
||||
|
||||
@@ -447,6 +451,40 @@ public class MembreResource {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Liste TOUS les membres (y compris EN_ATTENTE_VALIDATION) — réservé SUPER_ADMIN.
|
||||
* Utile pour les imports de données historiques et la gestion admin.
|
||||
*/
|
||||
@GET
|
||||
@Path("/admin/tous")
|
||||
@RolesAllowed({ "SUPER_ADMIN" })
|
||||
@Operation(summary = "Tous les membres (admin)", description = "Liste tous les membres quelque soit leur statut, réservé SUPER_ADMIN")
|
||||
@APIResponse(responseCode = "200", description = "Liste complète des membres")
|
||||
public Response getTousMembres(
|
||||
@Parameter(description = "Numéro de page (0-based)") @QueryParam("page") @DefaultValue("0") int page,
|
||||
@Parameter(description = "Taille de la page") @QueryParam("size") @DefaultValue("100") int size) {
|
||||
try {
|
||||
LOG.infof("GET /api/membres/admin/tous - page=%d size=%d", page, size);
|
||||
List<Membre> membres = membreRepository.findAll(
|
||||
io.quarkus.panache.common.Sort.by("nom").ascending())
|
||||
.page(io.quarkus.panache.common.Page.of(page, size))
|
||||
.list();
|
||||
List<MembreResponse> membresDTO = membreService.convertToResponseList(membres);
|
||||
long total = membreRepository.count();
|
||||
return Response.ok(Map.of(
|
||||
"data", membresDTO,
|
||||
"totalElements", total,
|
||||
"page", page,
|
||||
"size", size,
|
||||
"totalPages", (int) Math.ceil((double) total / size)
|
||||
)).build();
|
||||
} catch (Exception e) {
|
||||
LOG.errorf(e, "Erreur récupération tous membres");
|
||||
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
|
||||
.entity(Map.of("error", e.getMessage())).build();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Liste les membres d'une organisation spécifique (statut ACTIF dans l'organisation).
|
||||
* Utilisé pour la création de campagnes ciblées.
|
||||
@@ -588,7 +626,7 @@ public class MembreResource {
|
||||
@APIResponses({
|
||||
@APIResponse(responseCode = "200", description = "Recherche effectuée avec succès", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = MembreSearchResultDTO.class), examples = @ExampleObject(name = "Exemple de résultats", value = """
|
||||
{
|
||||
"membres": [...],
|
||||
"membres": [],
|
||||
"totalElements": 247,
|
||||
"totalPages": 13,
|
||||
"currentPage": 0,
|
||||
|
||||
@@ -11,6 +11,7 @@ import jakarta.validation.Valid;
|
||||
import jakarta.ws.rs.*;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import io.quarkus.security.identity.SecurityIdentity;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
@@ -24,10 +25,16 @@ public class TransactionEpargneResource {
|
||||
@Inject
|
||||
TransactionEpargneService transactionEpargneService;
|
||||
|
||||
@Inject
|
||||
SecurityIdentity securityIdentity;
|
||||
|
||||
@POST
|
||||
@RolesAllowed({ "ADMIN", "SUPER_ADMIN", "ADMIN_ORGANISATION", "MUTUELLE_RESP", "MEMBRE", "USER" })
|
||||
public Response executerTransaction(@Valid TransactionEpargneRequest request) {
|
||||
TransactionEpargneResponse transaction = transactionEpargneService.executerTransaction(request);
|
||||
public Response executerTransaction(
|
||||
@Valid TransactionEpargneRequest request,
|
||||
@QueryParam("historique") @DefaultValue("false") boolean historique) {
|
||||
boolean bypassSolde = historique && securityIdentity.hasRole("SUPER_ADMIN");
|
||||
TransactionEpargneResponse transaction = transactionEpargneService.executerTransaction(request, bypassSolde);
|
||||
return Response.status(Response.Status.CREATED).entity(transaction).build();
|
||||
}
|
||||
|
||||
|
||||
@@ -82,12 +82,15 @@ public class AlertMonitoringService {
|
||||
*/
|
||||
private void checkCpuThreshold(AlertConfiguration config) {
|
||||
try {
|
||||
OperatingSystemMXBean osBean = ManagementFactory.getOperatingSystemMXBean();
|
||||
double loadAvg = osBean.getSystemLoadAverage();
|
||||
int processors = osBean.getAvailableProcessors();
|
||||
|
||||
// Calculer l'utilisation CPU en pourcentage
|
||||
double cpuUsage = loadAvg < 0 ? 0.0 : Math.min(100.0, (loadAvg / processors) * 100.0);
|
||||
// getProcessCpuLoad() renvoie la charge CPU de CE process JVM (0.0-1.0),
|
||||
// ce qui est correct en conteneur K8s/Docker.
|
||||
// getSystemLoadAverage() renvoie la charge du NODE entier (hôte Linux),
|
||||
// divisée par availableProcessors() limité par le conteneur (ex: 1),
|
||||
// ce qui produit des faux positifs dès que le node est actif.
|
||||
com.sun.management.OperatingSystemMXBean osBean =
|
||||
(com.sun.management.OperatingSystemMXBean) ManagementFactory.getOperatingSystemMXBean();
|
||||
double processCpuLoad = osBean.getProcessCpuLoad();
|
||||
double cpuUsage = processCpuLoad < 0 ? 0.0 : Math.min(100.0, processCpuLoad * 100.0);
|
||||
lastCpuUsage = cpuUsage;
|
||||
|
||||
int threshold = config.getCpuThresholdPercent();
|
||||
|
||||
@@ -87,6 +87,25 @@ public class AuditService {
|
||||
auditLogRepository.persist(log);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enregistre un log d'audit KYC/AML quand un score de risque élevé est détecté.
|
||||
*/
|
||||
@Transactional
|
||||
public void logKycRisqueEleve(UUID membreId, int scoreRisque, String niveauRisque) {
|
||||
AuditLog log = new AuditLog();
|
||||
log.setTypeAction("KYC_RISQUE_ELEVE");
|
||||
log.setSeverite("WARNING");
|
||||
log.setUtilisateur(membreId != null ? membreId.toString() : null);
|
||||
log.setModule("KYC_AML");
|
||||
log.setDescription("Score de risque KYC/AML élevé détecté");
|
||||
log.setDetails(String.format("membreId=%s, score=%d, niveau=%s", membreId, scoreRisque, niveauRisque));
|
||||
log.setEntiteType("KycDossier");
|
||||
log.setEntiteId(membreId != null ? membreId.toString() : null);
|
||||
log.setDateHeure(LocalDateTime.now());
|
||||
log.setPortee(PorteeAudit.PLATEFORME);
|
||||
auditLogRepository.persist(log);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enregistre un nouveau log d'audit
|
||||
*/
|
||||
|
||||
@@ -2,7 +2,9 @@ package dev.lions.unionflow.server.service;
|
||||
|
||||
import dev.lions.unionflow.server.api.dto.comptabilite.request.*;
|
||||
import dev.lions.unionflow.server.api.dto.comptabilite.response.*;
|
||||
import dev.lions.unionflow.server.api.enums.comptabilite.TypeJournalComptable;
|
||||
import dev.lions.unionflow.server.entity.*;
|
||||
import dev.lions.unionflow.server.entity.mutuelle.epargne.TransactionEpargne;
|
||||
import dev.lions.unionflow.server.repository.*;
|
||||
import dev.lions.unionflow.server.service.KeycloakService;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
@@ -221,6 +223,207 @@ public class ComptabiliteService {
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// MÉTHODES SYSCOHADA — Génération automatique d'écritures depuis les opérations métier
|
||||
// Débit/Crédit selon les règles SYSCOHADA révisé (UEMOA, applicable depuis 2018)
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Génère l'écriture comptable SYSCOHADA pour une cotisation payée.
|
||||
* Schéma : Débit 5121xx (trésorerie provider) ; Crédit 706100 (cotisations ordinaires).
|
||||
* Appeler depuis CotisationService.marquerPaye() après confirmation du paiement.
|
||||
*/
|
||||
@Transactional
|
||||
public EcritureComptable enregistrerCotisation(Cotisation cotisation) {
|
||||
if (cotisation == null || cotisation.getOrganisation() == null) {
|
||||
LOG.warn("enregistrerCotisation : cotisation ou organisation null — écriture ignorée");
|
||||
return null;
|
||||
}
|
||||
|
||||
UUID orgId = cotisation.getOrganisation().getId();
|
||||
BigDecimal montant = cotisation.getMontantPaye();
|
||||
if (montant == null || montant.compareTo(BigDecimal.ZERO) == 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Choix du compte de trésorerie selon le provider (Wave par défaut)
|
||||
String numeroTresorerie = resolveCompteTresorerie(cotisation.getCodeDevise());
|
||||
CompteComptable compteTresorerie = compteComptableRepository
|
||||
.findByOrganisationAndNumero(orgId, numeroTresorerie)
|
||||
.orElseGet(() -> compteComptableRepository.findByOrganisationAndNumero(orgId, "512500").orElse(null));
|
||||
|
||||
// Compte produit cotisations ordinaires
|
||||
String numeroCompteType = "ORDINAIRE".equals(cotisation.getTypeCotisation()) ? "706100" : "706200";
|
||||
CompteComptable compteProduit = compteComptableRepository
|
||||
.findByOrganisationAndNumero(orgId, numeroCompteType)
|
||||
.orElseGet(() -> compteComptableRepository.findByOrganisationAndNumero(orgId, "706100").orElse(null));
|
||||
|
||||
if (compteTresorerie == null || compteProduit == null) {
|
||||
LOG.warnf("Comptes SYSCOHADA manquants pour org %s — plan comptable non initialisé ?", orgId);
|
||||
return null;
|
||||
}
|
||||
|
||||
JournalComptable journal = journalComptableRepository
|
||||
.findByOrganisationAndType(orgId, TypeJournalComptable.VENTES)
|
||||
.orElse(null);
|
||||
if (journal == null) {
|
||||
LOG.warnf("Journal VENTES absent pour org %s — écriture ignorée", orgId);
|
||||
return null;
|
||||
}
|
||||
|
||||
EcritureComptable ecriture = construireEcriture(
|
||||
journal,
|
||||
cotisation.getOrganisation(),
|
||||
LocalDate.now(),
|
||||
String.format("Cotisation %s - %s", cotisation.getTypeCotisation(), cotisation.getNumeroReference()),
|
||||
cotisation.getNumeroReference(),
|
||||
montant,
|
||||
compteTresorerie,
|
||||
compteProduit
|
||||
);
|
||||
|
||||
ecritureComptableRepository.persist(ecriture);
|
||||
LOG.infof("Écriture SYSCOHADA cotisation créée : %s | montant %s XOF", ecriture.getNumeroPiece(), montant);
|
||||
return ecriture;
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère l'écriture SYSCOHADA pour un dépôt épargne.
|
||||
* Schéma : Débit 5121xx (trésorerie) ; Crédit 421000 (dette mutuelle envers membre).
|
||||
*/
|
||||
@Transactional
|
||||
public EcritureComptable enregistrerDepotEpargne(TransactionEpargne transaction, Organisation organisation) {
|
||||
if (transaction == null || organisation == null) return null;
|
||||
|
||||
UUID orgId = organisation.getId();
|
||||
BigDecimal montant = transaction.getMontant();
|
||||
if (montant == null || montant.compareTo(BigDecimal.ZERO) <= 0) return null;
|
||||
|
||||
CompteComptable compteTresorerie = compteComptableRepository
|
||||
.findByOrganisationAndNumero(orgId, "512100")
|
||||
.orElseGet(() -> compteComptableRepository.findByOrganisationAndNumero(orgId, "512500").orElse(null));
|
||||
|
||||
CompteComptable compteEpargne = compteComptableRepository
|
||||
.findByOrganisationAndNumero(orgId, "421000").orElse(null);
|
||||
|
||||
if (compteTresorerie == null || compteEpargne == null) return null;
|
||||
|
||||
JournalComptable journal = journalComptableRepository
|
||||
.findByOrganisationAndType(orgId, TypeJournalComptable.BANQUE)
|
||||
.orElse(null);
|
||||
if (journal == null) return null;
|
||||
|
||||
EcritureComptable ecriture = construireEcriture(
|
||||
journal, organisation, LocalDate.now(),
|
||||
"Dépôt épargne - " + (transaction.getReferenceExterne() != null ? transaction.getReferenceExterne() : ""),
|
||||
transaction.getReferenceExterne(),
|
||||
montant, compteTresorerie, compteEpargne
|
||||
);
|
||||
|
||||
ecritureComptableRepository.persist(ecriture);
|
||||
LOG.infof("Écriture SYSCOHADA dépôt épargne : %s | %s XOF", ecriture.getNumeroPiece(), montant);
|
||||
return ecriture;
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère l'écriture SYSCOHADA pour un retrait épargne.
|
||||
* Schéma : Débit 421000 (dette mutuelle) ; Crédit 5121xx (trésorerie sortante).
|
||||
*/
|
||||
@Transactional
|
||||
public EcritureComptable enregistrerRetraitEpargne(TransactionEpargne transaction, Organisation organisation) {
|
||||
if (transaction == null || organisation == null) return null;
|
||||
|
||||
UUID orgId = organisation.getId();
|
||||
BigDecimal montant = transaction.getMontant();
|
||||
if (montant == null || montant.compareTo(BigDecimal.ZERO) <= 0) return null;
|
||||
|
||||
CompteComptable compteEpargne = compteComptableRepository
|
||||
.findByOrganisationAndNumero(orgId, "421000").orElse(null);
|
||||
|
||||
CompteComptable compteTresorerie = compteComptableRepository
|
||||
.findByOrganisationAndNumero(orgId, "512100")
|
||||
.orElseGet(() -> compteComptableRepository.findByOrganisationAndNumero(orgId, "512500").orElse(null));
|
||||
|
||||
if (compteEpargne == null || compteTresorerie == null) return null;
|
||||
|
||||
JournalComptable journal = journalComptableRepository
|
||||
.findByOrganisationAndType(orgId, TypeJournalComptable.BANQUE)
|
||||
.orElse(null);
|
||||
if (journal == null) return null;
|
||||
|
||||
// Retrait : débit = 421000 (dette diminue), crédit = 512xxx (cash sort)
|
||||
EcritureComptable ecriture = construireEcriture(
|
||||
journal, organisation, LocalDate.now(),
|
||||
"Retrait épargne - " + (transaction.getReferenceExterne() != null ? transaction.getReferenceExterne() : ""),
|
||||
transaction.getReferenceExterne(),
|
||||
montant, compteEpargne, compteTresorerie
|
||||
);
|
||||
|
||||
ecritureComptableRepository.persist(ecriture);
|
||||
return ecriture;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// MÉTHODES PRIVÉES - HELPERS SYSCOHADA
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Détermine le compte de trésorerie selon le code devise / provider.
|
||||
* Par défaut 512100 (Wave) pour XOF en UEMOA.
|
||||
*/
|
||||
private String resolveCompteTresorerie(String codeDevise) {
|
||||
// Pour l'instant Wave = 512100 par défaut. Sera enrichi avec multi-provider P1.3.
|
||||
return "512100";
|
||||
}
|
||||
|
||||
/**
|
||||
* Construit une écriture comptable à 2 lignes (débit/crédit) équilibrée.
|
||||
*/
|
||||
private EcritureComptable construireEcriture(
|
||||
JournalComptable journal,
|
||||
Organisation organisation,
|
||||
LocalDate date,
|
||||
String libelle,
|
||||
String reference,
|
||||
BigDecimal montant,
|
||||
CompteComptable compteDebit,
|
||||
CompteComptable compteCredit) {
|
||||
|
||||
LigneEcriture ligneDebit = new LigneEcriture();
|
||||
ligneDebit.setNumeroLigne(1);
|
||||
ligneDebit.setCompteComptable(compteDebit);
|
||||
ligneDebit.setMontantDebit(montant);
|
||||
ligneDebit.setMontantCredit(BigDecimal.ZERO);
|
||||
ligneDebit.setLibelle(libelle);
|
||||
ligneDebit.setReference(reference);
|
||||
|
||||
LigneEcriture ligneCredit = new LigneEcriture();
|
||||
ligneCredit.setNumeroLigne(2);
|
||||
ligneCredit.setCompteComptable(compteCredit);
|
||||
ligneCredit.setMontantDebit(BigDecimal.ZERO);
|
||||
ligneCredit.setMontantCredit(montant);
|
||||
ligneCredit.setLibelle(libelle);
|
||||
ligneCredit.setReference(reference);
|
||||
|
||||
EcritureComptable ecriture = EcritureComptable.builder()
|
||||
.journal(journal)
|
||||
.organisation(organisation)
|
||||
.dateEcriture(date)
|
||||
.libelle(libelle)
|
||||
.reference(reference)
|
||||
.montantDebit(montant)
|
||||
.montantCredit(montant)
|
||||
.pointe(false)
|
||||
.build();
|
||||
|
||||
ecriture.getLignes().add(ligneDebit);
|
||||
ecriture.getLignes().add(ligneCredit);
|
||||
ligneDebit.setEcriture(ecriture);
|
||||
ligneCredit.setEcriture(ecriture);
|
||||
|
||||
return ecriture;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// MÉTHODES PRIVÉES - CONVERSIONS
|
||||
// ========================================
|
||||
|
||||
@@ -11,6 +11,8 @@ import dev.lions.unionflow.server.repository.CotisationRepository;
|
||||
import dev.lions.unionflow.server.repository.MembreRepository;
|
||||
import dev.lions.unionflow.server.repository.OrganisationRepository;
|
||||
import dev.lions.unionflow.server.service.support.SecuriteHelper;
|
||||
import dev.lions.unionflow.server.service.ComptabiliteService;
|
||||
import dev.lions.unionflow.server.security.RlsEnabled;
|
||||
import io.quarkus.panache.common.Page;
|
||||
import io.quarkus.panache.common.Sort;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
@@ -43,6 +45,7 @@ import lombok.extern.slf4j.Slf4j;
|
||||
*/
|
||||
@ApplicationScoped
|
||||
@Slf4j
|
||||
@RlsEnabled
|
||||
public class CotisationService {
|
||||
|
||||
@Inject
|
||||
@@ -63,6 +66,12 @@ public class CotisationService {
|
||||
@Inject
|
||||
OrganisationService organisationService;
|
||||
|
||||
@Inject
|
||||
ComptabiliteService comptabiliteService;
|
||||
|
||||
@Inject
|
||||
EmailTemplateService emailTemplateService;
|
||||
|
||||
/**
|
||||
* Récupère toutes les cotisations avec pagination.
|
||||
*
|
||||
@@ -246,6 +255,7 @@ public class CotisationService {
|
||||
}
|
||||
|
||||
// Déterminer le statut en fonction du montant payé
|
||||
boolean etaitDejaPayee = "PAYEE".equals(cotisation.getStatut());
|
||||
if (cotisation.getMontantPaye() != null && cotisation.getMontantDu() != null
|
||||
&& cotisation.getMontantPaye().compareTo(cotisation.getMontantDu()) >= 0) {
|
||||
cotisation.setStatut("PAYEE");
|
||||
@@ -254,6 +264,36 @@ public class CotisationService {
|
||||
cotisation.setStatut("PARTIELLEMENT_PAYEE");
|
||||
}
|
||||
|
||||
// Génération écriture SYSCOHADA + email si cotisation vient de passer à PAYEE
|
||||
if (!etaitDejaPayee && "PAYEE".equals(cotisation.getStatut())) {
|
||||
try {
|
||||
comptabiliteService.enregistrerCotisation(cotisation);
|
||||
} catch (Exception e) {
|
||||
log.warn("Écriture SYSCOHADA cotisation ignorée (non bloquant) : {}", e.getMessage());
|
||||
}
|
||||
// Email de confirmation asynchrone (non bloquant)
|
||||
if (cotisation.getMembre() != null && cotisation.getMembre().getEmail() != null) {
|
||||
try {
|
||||
String periode = cotisation.getPeriode() != null ? cotisation.getPeriode()
|
||||
: (cotisation.getDateEcheance() != null
|
||||
? cotisation.getDateEcheance().getYear() + "/" + cotisation.getDateEcheance().getMonthValue()
|
||||
: "—");
|
||||
emailTemplateService.envoyerConfirmationCotisation(
|
||||
cotisation.getMembre().getEmail(),
|
||||
cotisation.getMembre().getPrenom() != null ? cotisation.getMembre().getPrenom() : "",
|
||||
cotisation.getMembre().getNom() != null ? cotisation.getMembre().getNom() : "",
|
||||
cotisation.getOrganisation() != null ? cotisation.getOrganisation().getNom() : "",
|
||||
periode,
|
||||
reference != null ? reference : "",
|
||||
modePaiement != null ? modePaiement : "—",
|
||||
datePaiement,
|
||||
cotisation.getMontantPaye());
|
||||
} catch (Exception e) {
|
||||
log.warn("Email confirmation cotisation ignoré (non bloquant) : {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.info("Paiement enregistré - ID: {}, Statut: {}", id, cotisation.getStatut());
|
||||
return convertToResponse(cotisation);
|
||||
}
|
||||
|
||||
@@ -89,6 +89,9 @@ public class MembreKeycloakSyncService {
|
||||
@RestClient
|
||||
AdminRoleServiceClient roleServiceClient;
|
||||
|
||||
@Inject
|
||||
EmailTemplateService emailTemplateService;
|
||||
|
||||
/**
|
||||
* Provisionne un compte Keycloak pour un Membre existant qui n'en a pas encore.
|
||||
*
|
||||
@@ -193,20 +196,37 @@ public class MembreKeycloakSyncService {
|
||||
* @param membreId UUID du membre à activer dans Keycloak
|
||||
* @throws NotFoundException si le membre n'existe pas en base
|
||||
*/
|
||||
@Transactional
|
||||
@Transactional(jakarta.transaction.Transactional.TxType.REQUIRES_NEW)
|
||||
public void activerMembreDansKeycloak(java.util.UUID membreId) {
|
||||
LOGGER.info("Activation Keycloak (rôle MEMBRE_ACTIF) pour Membre ID: " + membreId);
|
||||
|
||||
Membre membre = membreRepository.findByIdOptional(membreId)
|
||||
.orElseThrow(() -> new NotFoundException("Membre non trouvé avec l'ID: " + membreId));
|
||||
|
||||
// Provisionner le compte Keycloak s'il n'existe pas encore
|
||||
// Lier le compte Keycloak si absent : chercher par email avant de tenter un provisionnement
|
||||
if (membre.getKeycloakId() == null) {
|
||||
LOGGER.info("Compte Keycloak absent — provisionnement automatique pour " + membre.getNomComplet());
|
||||
provisionKeycloakUser(membreId);
|
||||
// Recharger après persist dans provisionKeycloakUser
|
||||
try {
|
||||
UserSearchCriteriaDTO criteria = new UserSearchCriteriaDTO();
|
||||
criteria.setEmail(membre.getEmail());
|
||||
criteria.setRealmName(DEFAULT_REALM);
|
||||
criteria.setPageSize(1);
|
||||
var result = userServiceClient.searchUsers(criteria);
|
||||
if (result != null && result.getUsers() != null && !result.getUsers().isEmpty()) {
|
||||
String kcId = result.getUsers().get(0).getId();
|
||||
membre.setKeycloakId(UUID.fromString(kcId));
|
||||
membreRepository.persist(membre);
|
||||
LOGGER.info("Compte Keycloak existant lié pour " + membre.getEmail() + " → " + kcId);
|
||||
} else {
|
||||
LOGGER.info("Compte Keycloak absent — provisionnement pour " + membre.getNomComplet());
|
||||
provisionKeycloakUser(membreId);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOGGER.warning("Recherche Keycloak par email échouée, tentative provisionnement : " + e.getMessage());
|
||||
provisionKeycloakUser(membreId);
|
||||
}
|
||||
// Recharger après liaison/provisionnement
|
||||
membre = membreRepository.findByIdOptional(membreId)
|
||||
.orElseThrow(() -> new NotFoundException("Membre non trouvé après provisionnement: " + membreId));
|
||||
.orElseThrow(() -> new NotFoundException("Membre non trouvé après liaison Keycloak: " + membreId));
|
||||
}
|
||||
|
||||
String keycloakUserId = membre.getKeycloakId().toString();
|
||||
@@ -247,19 +267,36 @@ public class MembreKeycloakSyncService {
|
||||
* @param membreId UUID du membre à promouvoir dans Keycloak
|
||||
* @throws NotFoundException si le membre n'existe pas en base
|
||||
*/
|
||||
@Transactional
|
||||
@Transactional(jakarta.transaction.Transactional.TxType.REQUIRES_NEW)
|
||||
public void promouvoirAdminOrganisationDansKeycloak(java.util.UUID membreId) {
|
||||
LOGGER.info("Promotion Keycloak (rôle ADMIN_ORGANISATION) pour Membre ID: " + membreId);
|
||||
|
||||
Membre membre = membreRepository.findByIdOptional(membreId)
|
||||
.orElseThrow(() -> new NotFoundException("Membre non trouvé avec l'ID: " + membreId));
|
||||
|
||||
// Provisionner le compte Keycloak s'il n'existe pas encore
|
||||
// Lier le compte Keycloak si absent : chercher par email avant de tenter un provisionnement
|
||||
if (membre.getKeycloakId() == null) {
|
||||
LOGGER.info("Compte Keycloak absent — provisionnement automatique pour " + membre.getNomComplet());
|
||||
provisionKeycloakUser(membreId);
|
||||
try {
|
||||
UserSearchCriteriaDTO criteria = new UserSearchCriteriaDTO();
|
||||
criteria.setEmail(membre.getEmail());
|
||||
criteria.setRealmName(DEFAULT_REALM);
|
||||
criteria.setPageSize(1);
|
||||
var result = userServiceClient.searchUsers(criteria);
|
||||
if (result != null && result.getUsers() != null && !result.getUsers().isEmpty()) {
|
||||
String kcId = result.getUsers().get(0).getId();
|
||||
membre.setKeycloakId(UUID.fromString(kcId));
|
||||
membreRepository.persist(membre);
|
||||
LOGGER.info("Compte Keycloak existant lié pour " + membre.getEmail() + " → " + kcId);
|
||||
} else {
|
||||
LOGGER.info("Compte Keycloak absent — provisionnement pour " + membre.getNomComplet());
|
||||
provisionKeycloakUser(membreId);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOGGER.warning("Recherche Keycloak par email échouée, tentative provisionnement : " + e.getMessage());
|
||||
provisionKeycloakUser(membreId);
|
||||
}
|
||||
membre = membreRepository.findByIdOptional(membreId)
|
||||
.orElseThrow(() -> new NotFoundException("Membre non trouvé après provisionnement: " + membreId));
|
||||
.orElseThrow(() -> new NotFoundException("Membre non trouvé après liaison Keycloak: " + membreId));
|
||||
}
|
||||
|
||||
String keycloakUserId = membre.getKeycloakId().toString();
|
||||
@@ -735,6 +772,28 @@ public class MembreKeycloakSyncService {
|
||||
}
|
||||
}
|
||||
LOGGER.info("Premier login complété pour : " + membre.getEmail());
|
||||
|
||||
// Email de bienvenue (non bloquant)
|
||||
if (doitActiver && membre.getEmail() != null) {
|
||||
try {
|
||||
String orgNom = "";
|
||||
try {
|
||||
var memberships = membre.getMembresOrganisations();
|
||||
if (memberships != null && !memberships.isEmpty()) {
|
||||
orgNom = memberships.iterator().next().getOrganisation().getNom();
|
||||
}
|
||||
} catch (Exception ignore) {}
|
||||
emailTemplateService.envoyerBienvenue(
|
||||
membre.getEmail(),
|
||||
membre.getPrenom() != null ? membre.getPrenom() : "",
|
||||
membre.getNom() != null ? membre.getNom() : "",
|
||||
orgNom,
|
||||
null);
|
||||
} catch (Exception e) {
|
||||
LOGGER.warning("Email bienvenue ignoré (non bloquant) : " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return PremierLoginResultat.COMPLETE;
|
||||
}
|
||||
|
||||
|
||||
@@ -1283,6 +1283,25 @@ public class MembreService {
|
||||
.getSingleResult() > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si une organisation a reçu un paiement (confirmé ou validé).
|
||||
* Utilisé pour auto-activer l'admin dès que le paiement est reçu,
|
||||
* sans attendre la validation super admin.
|
||||
*
|
||||
* @param orgId UUID de l'organisation
|
||||
* @return true si la souscription est ACTIVE ou en PAIEMENT_CONFIRME/VALIDEE
|
||||
*/
|
||||
public boolean orgHasPaidSubscription(UUID orgId) {
|
||||
if (orgId == null) return false;
|
||||
return entityManager.createQuery(
|
||||
"SELECT COUNT(s) FROM SouscriptionOrganisation s " +
|
||||
"WHERE s.organisation.id = :orgId " +
|
||||
"AND (s.statut = 'ACTIVE' OR s.statutValidation IN ('PAIEMENT_CONFIRME', 'VALIDEE'))",
|
||||
Long.class)
|
||||
.setParameter("orgId", orgId)
|
||||
.getSingleResult() > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lie un membre à une organisation et incrémente le quota de la souscription.
|
||||
* Utilisé lors de la création unitaire ou de l'import massif.
|
||||
|
||||
@@ -51,6 +51,9 @@ public class NotificationService {
|
||||
@Inject
|
||||
KeycloakService keycloakService;
|
||||
|
||||
@Inject
|
||||
FirebasePushService firebasePushService;
|
||||
|
||||
/**
|
||||
* Crée un nouveau template de notification
|
||||
*
|
||||
@@ -91,14 +94,19 @@ public class NotificationService {
|
||||
notificationRepository.persist(notification);
|
||||
LOG.infof("Notification créée avec succès: ID=%s", notification.getId());
|
||||
|
||||
// Envoi immédiat si type EMAIL
|
||||
// Envoi immédiat selon le canal
|
||||
if ("EMAIL".equals(notification.getTypeNotification())) {
|
||||
try {
|
||||
envoyerEmail(notification);
|
||||
} catch (Exception e) {
|
||||
LOG.errorf("Erreur lors de l'envoi de l'email pour la notification %s: %s", notification.getId(),
|
||||
e.getMessage());
|
||||
// On ne relance pas l'exception pour ne pas bloquer la transaction de création
|
||||
}
|
||||
} else if ("PUSH".equals(notification.getTypeNotification())) {
|
||||
try {
|
||||
envoyerPush(notification);
|
||||
} catch (Exception e) {
|
||||
LOG.warnf("Erreur push notification %s (non bloquant): %s", notification.getId(), e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -381,6 +389,38 @@ public class NotificationService {
|
||||
return notification;
|
||||
}
|
||||
|
||||
/**
|
||||
* Envoie une notification push FCM pour une notification.
|
||||
*/
|
||||
private void envoyerPush(Notification notification) {
|
||||
if (notification.getMembre() == null) {
|
||||
LOG.warnf("Impossible d'envoyer le push pour la notification %s : pas de membre", notification.getId());
|
||||
notification.setStatut("ECHEC_ENVOI");
|
||||
notification.setMessageErreur("Pas de membre défini");
|
||||
return;
|
||||
}
|
||||
String fcmToken = notification.getMembre().getFcmToken();
|
||||
if (fcmToken == null || fcmToken.isBlank()) {
|
||||
LOG.debugf("Membre %s sans token FCM — push ignoré", notification.getMembre().getId());
|
||||
notification.setStatut("IGNOREE");
|
||||
notification.setMessageErreur("Pas de token FCM");
|
||||
return;
|
||||
}
|
||||
boolean ok = firebasePushService.envoyerNotification(
|
||||
fcmToken,
|
||||
notification.getSujet(),
|
||||
notification.getCorps(),
|
||||
java.util.Map.of("notificationId", notification.getId().toString()));
|
||||
if (ok) {
|
||||
notification.setStatut("ENVOYEE");
|
||||
notification.setDateEnvoi(java.time.LocalDateTime.now());
|
||||
} else {
|
||||
notification.setStatut("ECHEC_ENVOI");
|
||||
notification.setMessageErreur("FCM: envoi échoué");
|
||||
}
|
||||
notificationRepository.persist(notification);
|
||||
}
|
||||
|
||||
/**
|
||||
* Envoie un email pour une notification
|
||||
*/
|
||||
@@ -394,9 +434,12 @@ public class NotificationService {
|
||||
|
||||
try {
|
||||
LOG.infof("Envoi de l'email à %s", notification.getMembre().getEmail());
|
||||
mailer.send(Mail.withText(notification.getMembre().getEmail(),
|
||||
notification.getSujet(),
|
||||
notification.getCorps())); // TODO: Support HTML body if needed
|
||||
String corps = notification.getCorps();
|
||||
boolean isHtml = corps != null && (corps.startsWith("<html") || corps.startsWith("<!DOCTYPE") || corps.startsWith("<HTML"));
|
||||
Mail mail = isHtml
|
||||
? Mail.withHtml(notification.getMembre().getEmail(), notification.getSujet(), corps)
|
||||
: Mail.withText(notification.getMembre().getEmail(), notification.getSujet(), corps);
|
||||
mailer.send(mail);
|
||||
|
||||
notification.setStatut("ENVOYEE");
|
||||
notification.setDateEnvoi(LocalDateTime.now());
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package dev.lions.unionflow.server.service;
|
||||
|
||||
import dev.lions.unionflow.server.api.dto.paiement.request.CreatePaiementRequest;
|
||||
import dev.lions.unionflow.server.api.payment.PaymentEvent;
|
||||
import dev.lions.unionflow.server.api.payment.PaymentStatus;
|
||||
import dev.lions.unionflow.server.api.dto.paiement.response.PaiementResponse;
|
||||
import dev.lions.unionflow.server.api.dto.paiement.response.PaiementSummaryResponse;
|
||||
import dev.lions.unionflow.server.api.dto.reference.response.TypeReferenceResponse;
|
||||
@@ -66,6 +68,12 @@ public class PaiementService {
|
||||
@Inject
|
||||
io.quarkus.security.identity.SecurityIdentity securityIdentity;
|
||||
|
||||
@Inject
|
||||
dev.lions.unionflow.server.repository.MembreOrganisationRepository membreOrganisationRepository;
|
||||
|
||||
@Inject
|
||||
NotificationService notificationService;
|
||||
|
||||
/**
|
||||
* Crée un nouveau paiement
|
||||
*
|
||||
@@ -509,13 +517,30 @@ public class PaiementService {
|
||||
|
||||
paiementRepository.persist(paiement);
|
||||
|
||||
// TODO: Créer une notification pour le trésorier
|
||||
// notificationService.creerNotification(
|
||||
// "VALIDATION_PAIEMENT_REQUIS",
|
||||
// "Validation paiement manuel requis",
|
||||
// "Le membre " + membreConnecte.getNumeroMembre() + " a déclaré un paiement manuel à valider.",
|
||||
// tresorierIds
|
||||
// );
|
||||
// Notifier les trésoriers de l'organisation que ce paiement manuel attend validation
|
||||
try {
|
||||
membreOrganisationRepository.findFirstByMembreId(membreConnecte.getId())
|
||||
.map(mo -> mo.getOrganisation().getId())
|
||||
.ifPresent(orgId -> {
|
||||
List<UUID> tresorierIds = membreOrganisationRepository
|
||||
.findByRoleOrgAndOrganisationId("TRESORIER", orgId)
|
||||
.stream()
|
||||
.map(mo -> mo.getMembre().getId())
|
||||
.collect(Collectors.toList());
|
||||
if (!tresorierIds.isEmpty()) {
|
||||
notificationService.envoyerNotificationsGroupees(
|
||||
tresorierIds,
|
||||
"Validation paiement manuel requis",
|
||||
"Le membre " + membreConnecte.getNumeroMembre()
|
||||
+ " a déclaré un paiement manuel (" + paiement.getNumeroReference()
|
||||
+ ") à valider.",
|
||||
List.of("IN_APP"));
|
||||
}
|
||||
});
|
||||
} catch (Exception e) {
|
||||
LOG.warnf("Erreur notification trésorier pour paiement %s (non bloquant): %s",
|
||||
paiement.getNumeroReference(), e.getMessage());
|
||||
}
|
||||
|
||||
LOG.infof("Paiement manuel déclaré avec succès: ID=%s, Référence=%s (EN_ATTENTE_VALIDATION)",
|
||||
paiement.getId(), paiement.getNumeroReference());
|
||||
@@ -586,6 +611,39 @@ public class PaiementService {
|
||||
.build();
|
||||
}
|
||||
|
||||
// ── Webhook multi-provider ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Met à jour le statut d'un paiement depuis un événement webhook normalisé.
|
||||
* Appelé par PaymentOrchestrator.handleEvent() — aucun contexte utilisateur requis.
|
||||
*/
|
||||
@Transactional
|
||||
public void mettreAJourStatutDepuisWebhook(PaymentEvent event) {
|
||||
Optional<Paiement> opt = paiementRepository.findByNumeroReference(event.reference());
|
||||
if (opt.isEmpty()) {
|
||||
LOG.warnf("Webhook reçu pour référence inconnue : %s (provider externalId=%s)",
|
||||
event.reference(), event.externalId());
|
||||
return;
|
||||
}
|
||||
Paiement paiement = opt.get();
|
||||
PaymentStatus status = event.status();
|
||||
|
||||
if (PaymentStatus.SUCCESS.equals(status)) {
|
||||
paiement.setStatutPaiement("PAIEMENT_CONFIRME");
|
||||
paiement.setDateValidation(LocalDateTime.now());
|
||||
paiement.setReferenceExterne(event.externalId());
|
||||
} else if (PaymentStatus.FAILED.equals(status) || PaymentStatus.CANCELLED.equals(status)
|
||||
|| PaymentStatus.EXPIRED.equals(status)) {
|
||||
paiement.setStatutPaiement("ANNULE");
|
||||
paiement.setReferenceExterne(event.externalId());
|
||||
}
|
||||
// INITIATED / PROCESSING : aucun changement de statut requis
|
||||
|
||||
paiementRepository.persist(paiement);
|
||||
LOG.infof("Statut paiement mis à jour via webhook : ref=%s statut=%s → %s",
|
||||
event.reference(), status, paiement.getStatutPaiement());
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// MÉTHODES PRIVÉES
|
||||
// ========================================
|
||||
|
||||
@@ -88,6 +88,9 @@ public class SouscriptionService {
|
||||
@Inject
|
||||
MembreKeycloakSyncService keycloakSyncService;
|
||||
|
||||
@Inject
|
||||
EmailTemplateService emailTemplateService;
|
||||
|
||||
// ── Catalogue ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -302,6 +305,9 @@ public class SouscriptionService {
|
||||
} catch (Exception e) {
|
||||
LOG.errorf("Activation compte échouée après paiement souscription=%s: %s — la souscription reste VALIDEE", souscriptionId, e.getMessage());
|
||||
}
|
||||
|
||||
// Email de confirmation de souscription (non bloquant)
|
||||
envoyerEmailSouscriptionActive(souscription, dateDebut, dateFin);
|
||||
}
|
||||
|
||||
// ── Validation SuperAdmin ──────────────────────────────────────────────────
|
||||
@@ -399,6 +405,9 @@ public class SouscriptionService {
|
||||
// Activer le membre admin de l'organisation
|
||||
activerAdminOrganisation(souscription.getOrganisation().getId());
|
||||
|
||||
// Email de confirmation de souscription (non bloquant)
|
||||
envoyerEmailSouscriptionActive(souscription, dateDebut, dateFin);
|
||||
|
||||
LOG.infof("Souscription %s approuvée — compte actif jusqu'au %s", souscriptionId, dateFin);
|
||||
}
|
||||
|
||||
@@ -615,6 +624,34 @@ public class SouscriptionService {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Email notifications ───────────────────────────────────────────────────
|
||||
|
||||
private void envoyerEmailSouscriptionActive(SouscriptionOrganisation s,
|
||||
LocalDate dateDebut, LocalDate dateFin) {
|
||||
try {
|
||||
String email = securiteHelper.resolveEmail();
|
||||
if (email == null) return;
|
||||
Membre admin = membreRepository.findByEmail(email).orElse(null);
|
||||
if (admin == null || admin.getEmail() == null) return;
|
||||
|
||||
FormuleAbonnement f = s.getFormule();
|
||||
emailTemplateService.envoyerConfirmationSouscription(
|
||||
admin.getEmail(),
|
||||
(admin.getPrenom() != null ? admin.getPrenom() : "") + " " + (admin.getNom() != null ? admin.getNom() : ""),
|
||||
s.getOrganisation() != null ? s.getOrganisation().getNom() : "",
|
||||
f != null && f.getLibelle() != null ? f.getLibelle() : "",
|
||||
s.getMontantTotal() != null ? s.getMontantTotal() : BigDecimal.ZERO,
|
||||
s.getTypePeriode() != null ? s.getTypePeriode().name() : "MENSUEL",
|
||||
dateDebut, dateFin,
|
||||
f != null ? f.getMaxMembres() : null,
|
||||
f != null ? f.getMaxStockageMo() : null,
|
||||
f != null && Boolean.TRUE.equals(f.getApiAccess()),
|
||||
f != null && Boolean.TRUE.equals(f.getSupportPrioritaire()));
|
||||
} catch (Exception e) {
|
||||
LOG.warnf("Email souscription ignoré (non bloquant) : %s", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// ── Matrice tarifaire de référence ────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -13,6 +13,8 @@ import dev.lions.unionflow.server.repository.ParametresLcbFtRepository;
|
||||
import dev.lions.unionflow.server.repository.mutuelle.epargne.CompteEpargneRepository;
|
||||
import dev.lions.unionflow.server.repository.mutuelle.epargne.TransactionEpargneRepository;
|
||||
import dev.lions.unionflow.server.service.AuditService;
|
||||
import dev.lions.unionflow.server.service.ComptabiliteService;
|
||||
import dev.lions.unionflow.server.security.RlsEnabled;
|
||||
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.inject.Inject;
|
||||
@@ -31,6 +33,7 @@ import java.util.stream.Collectors;
|
||||
* Applique les règles LCB-FT : origine des fonds obligatoire au-dessus du seuil configuré.
|
||||
*/
|
||||
@ApplicationScoped
|
||||
@RlsEnabled
|
||||
public class TransactionEpargneService {
|
||||
|
||||
/** Seuil LCB-FT (XOF) par défaut si aucun paramètre en base. */
|
||||
@@ -56,6 +59,9 @@ public class TransactionEpargneService {
|
||||
@Inject
|
||||
dev.lions.unionflow.server.service.AlerteLcbFtService alerteLcbFtService;
|
||||
|
||||
@Inject
|
||||
ComptabiliteService comptabiliteService;
|
||||
|
||||
/**
|
||||
* Enregistre une nouvelle transaction et met à jour le solde du compte.
|
||||
*
|
||||
@@ -64,6 +70,11 @@ public class TransactionEpargneService {
|
||||
*/
|
||||
@Transactional
|
||||
public TransactionEpargneResponse executerTransaction(TransactionEpargneRequest request) {
|
||||
return executerTransaction(request, false);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public TransactionEpargneResponse executerTransaction(TransactionEpargneRequest request, boolean bypassSolde) {
|
||||
CompteEpargne compte = compteEpargneRepository.findByIdOptional(UUID.fromString(request.getCompteId()))
|
||||
.orElseThrow(() -> new NotFoundException("Compte non trouvé avec l'ID: " + request.getCompteId()));
|
||||
|
||||
@@ -85,13 +96,13 @@ public class TransactionEpargneService {
|
||||
soldeApres = soldeAvant.add(montant);
|
||||
compte.setSoldeActuel(soldeApres);
|
||||
} else if (isTypeDebit(request.getTypeTransaction())) {
|
||||
if (getSoldeDisponible(compte).compareTo(montant) < 0) {
|
||||
if (!bypassSolde && getSoldeDisponible(compte).compareTo(montant) < 0) {
|
||||
throw new IllegalArgumentException("Solde disponible insuffisant pour cette opération.");
|
||||
}
|
||||
soldeApres = soldeAvant.subtract(montant);
|
||||
compte.setSoldeActuel(soldeApres);
|
||||
} else if (request.getTypeTransaction() == TypeTransactionEpargne.RETENUE_GARANTIE) {
|
||||
if (getSoldeDisponible(compte).compareTo(montant) < 0) {
|
||||
if (!bypassSolde && getSoldeDisponible(compte).compareTo(montant) < 0) {
|
||||
throw new IllegalArgumentException("Solde disponible insuffisant pour geler ce montant.");
|
||||
}
|
||||
compte.setSoldeBloque(compte.getSoldeBloque().add(montant));
|
||||
@@ -125,6 +136,19 @@ public class TransactionEpargneService {
|
||||
|
||||
transactionEpargneRepository.persist(transaction);
|
||||
|
||||
// Génération écriture SYSCOHADA (non bloquant)
|
||||
if (compte.getOrganisation() != null) {
|
||||
try {
|
||||
if (request.getTypeTransaction() == TypeTransactionEpargne.DEPOT) {
|
||||
comptabiliteService.enregistrerDepotEpargne(transaction, compte.getOrganisation());
|
||||
} else if (request.getTypeTransaction() == TypeTransactionEpargne.RETRAIT) {
|
||||
comptabiliteService.enregistrerRetraitEpargne(transaction, compte.getOrganisation());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// Écriture comptable non bloquante — la transaction épargne reste valide
|
||||
}
|
||||
}
|
||||
|
||||
if (request.getMontant() != null && request.getMontant().compareTo(seuil) >= 0) {
|
||||
UUID orgId = compte.getOrganisation() != null ? compte.getOrganisation().getId() : null;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user