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) */
|
/** Classe comptable (1-7) */
|
||||||
@NotNull
|
@NotNull
|
||||||
@Min(value = 1, 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 = 7, message = "La classe comptable doit être entre 1 et 7")
|
@Max(value = 9, message = "La classe comptable doit être entre 1 et 9")
|
||||||
@Column(name = "classe_comptable", nullable = false)
|
@Column(name = "classe_comptable", nullable = false)
|
||||||
private Integer classeComptable;
|
private Integer classeComptable;
|
||||||
|
|
||||||
@@ -85,6 +85,11 @@ public class CompteComptable extends BaseEntity {
|
|||||||
@Column(name = "description", length = 500)
|
@Column(name = "description", length = 500)
|
||||||
private String description;
|
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 */
|
/** Lignes d'écriture associées */
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
@OneToMany(mappedBy = "compteComptable", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
|
@OneToMany(mappedBy = "compteComptable", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
|
||||||
|
|||||||
@@ -110,6 +110,10 @@ public class FormuleAbonnement extends BaseEntity {
|
|||||||
@Column(name = "max_admins")
|
@Column(name = "max_admins")
|
||||||
private Integer maxAdmins;
|
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() {
|
public boolean isIllimitee() {
|
||||||
return maxMembres == null;
|
return maxMembres == null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,8 +24,11 @@ import lombok.NoArgsConstructor;
|
|||||||
@Entity
|
@Entity
|
||||||
@Table(
|
@Table(
|
||||||
name = "journaux_comptables",
|
name = "journaux_comptables",
|
||||||
|
uniqueConstraints = {
|
||||||
|
@UniqueConstraint(name = "uk_journaux_org_code", columnNames = {"organisation_id", "code"})
|
||||||
|
},
|
||||||
indexes = {
|
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_type", columnList = "type_journal"),
|
||||||
@Index(name = "idx_journal_periode", columnList = "date_debut, date_fin")
|
@Index(name = "idx_journal_periode", columnList = "date_debut, date_fin")
|
||||||
})
|
})
|
||||||
@@ -36,9 +39,9 @@ import lombok.NoArgsConstructor;
|
|||||||
@EqualsAndHashCode(callSuper = true)
|
@EqualsAndHashCode(callSuper = true)
|
||||||
public class JournalComptable extends BaseEntity {
|
public class JournalComptable extends BaseEntity {
|
||||||
|
|
||||||
/** Code unique du journal */
|
/** Code du journal (unique par organisation). */
|
||||||
@NotBlank
|
@NotBlank
|
||||||
@Column(name = "code", unique = true, nullable = false, length = 10)
|
@Column(name = "code", nullable = false, length = 10)
|
||||||
private String code;
|
private String code;
|
||||||
|
|
||||||
/** Libellé du journal */
|
/** Libellé du journal */
|
||||||
@@ -69,6 +72,11 @@ public class JournalComptable extends BaseEntity {
|
|||||||
@Column(name = "description", length = 500)
|
@Column(name = "description", length = 500)
|
||||||
private String description;
|
private String description;
|
||||||
|
|
||||||
|
/** Organisation propriétaire */
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "organisation_id")
|
||||||
|
private Organisation organisation;
|
||||||
|
|
||||||
/** Écritures comptables associées */
|
/** Écritures comptables associées */
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
@OneToMany(mappedBy = "journal", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
|
@OneToMany(mappedBy = "journal", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
|
||||||
|
|||||||
@@ -59,6 +59,10 @@ public class Membre extends BaseEntity {
|
|||||||
@Column(name = "telephone", length = 20)
|
@Column(name = "telephone", length = 20)
|
||||||
private String telephone;
|
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)")
|
@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)
|
@Column(name = "telephone_wave", length = 20)
|
||||||
private String telephoneWave;
|
private String telephoneWave;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import java.time.LocalDate;
|
|||||||
import java.time.Period;
|
import java.time.Period;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
import lombok.AllArgsConstructor;
|
import lombok.AllArgsConstructor;
|
||||||
import lombok.Builder;
|
import lombok.Builder;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
@@ -201,6 +202,10 @@ public class Organisation extends BaseEntity {
|
|||||||
@Column(name = "categorie_type", length = 50)
|
@Column(name = "categorie_type", length = 50)
|
||||||
private String categorieType;
|
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") */
|
/** Modules activés pour cette organisation (liste CSV, ex: "MEMBRES,COTISATIONS,TONTINE") */
|
||||||
@Column(name = "modules_actifs", length = 1000)
|
@Column(name = "modules_actifs", length = 1000)
|
||||||
private String modulesActifs;
|
private String modulesActifs;
|
||||||
|
|||||||
@@ -98,7 +98,9 @@ public class GlobalExceptionMapper implements ExceptionMapper<Throwable> {
|
|||||||
return exception instanceof NotFoundException
|
return exception instanceof NotFoundException
|
||||||
|| exception instanceof ForbiddenException
|
|| exception instanceof ForbiddenException
|
||||||
|| exception instanceof NotAuthorizedException
|
|| exception instanceof NotAuthorizedException
|
||||||
|| exception instanceof NotAllowedException;
|
|| exception instanceof NotAllowedException
|
||||||
|
|| exception instanceof IllegalArgumentException
|
||||||
|
|| exception instanceof IllegalStateException;
|
||||||
}
|
}
|
||||||
|
|
||||||
private int determineStatusCode(Throwable exception) {
|
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)
|
return find("typeCompte = ?1 AND actif = true ORDER BY numeroCompte ASC", TypeCompteComptable.TRESORERIE)
|
||||||
.list();
|
.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) {
|
public List<EcritureComptable> findByLettrage(String lettrage) {
|
||||||
return find("lettrage = ?1 AND actif = true ORDER BY dateEcriture DESC", lettrage).list();
|
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() {
|
public List<JournalComptable> findAllActifs() {
|
||||||
return find("actif = true ORDER BY code ASC").list();
|
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();
|
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.
|
* Trouve les membres en attente de validation depuis plus de N jours.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package dev.lions.unionflow.server.resource;
|
package dev.lions.unionflow.server.resource;
|
||||||
|
|
||||||
import dev.lions.unionflow.server.api.dto.membre.CompteAdherentResponse;
|
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.Membre;
|
||||||
import dev.lions.unionflow.server.entity.MembreOrganisation;
|
import dev.lions.unionflow.server.entity.MembreOrganisation;
|
||||||
import dev.lions.unionflow.server.entity.SouscriptionOrganisation;
|
import dev.lions.unionflow.server.entity.SouscriptionOrganisation;
|
||||||
@@ -67,6 +68,59 @@ public class CompteAdherentResource {
|
|||||||
@Inject
|
@Inject
|
||||||
MembreService membreService;
|
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é :
|
* Retourne le compte adhérent complet du membre connecté :
|
||||||
* numéro de membre, soldes (cotisations + épargne), capacité d'emprunt, taux d'engagement.
|
* 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
|
// Fallback : auto-activer si EN_ATTENTE_VALIDATION et org a reçu un paiement.
|
||||||
// (membres sans premiereConnexion=true ou créés avant cette logique)
|
// 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()) {
|
if ("EN_ATTENTE_VALIDATION".equals(statutCompte) && membreOpt.isPresent()) {
|
||||||
Membre m = membreOpt.get();
|
Membre m = membreOpt.get();
|
||||||
UUID orgId = membreOrganisationRepo.findFirstByMembreId(m.getId())
|
UUID orgId = membreOrganisationRepo.findFirstByMembreId(m.getId())
|
||||||
.map(mo -> mo.getOrganisation().getId())
|
.map(mo -> mo.getOrganisation().getId())
|
||||||
.orElse(null);
|
.orElse(null);
|
||||||
if (membreService.orgHasActiveSubscription(orgId)) {
|
if (membreService.orgHasPaidSubscription(orgId)) {
|
||||||
LOG.infof("Auto-activation au login de %s (org %s a souscription active)", m.getEmail(), orgId);
|
LOG.infof("Auto-activation au login de %s (org %s a souscription payée)", m.getEmail(), orgId);
|
||||||
membreService.activerMembre(m.getId());
|
membreService.activerMembre(m.getId());
|
||||||
try {
|
try {
|
||||||
membreKeycloakSyncService.activerMembreDansKeycloak(m.getId());
|
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.Organisation;
|
||||||
import dev.lions.unionflow.server.entity.MembreOrganisation;
|
import dev.lions.unionflow.server.entity.MembreOrganisation;
|
||||||
import dev.lions.unionflow.server.repository.MembreOrganisationRepository;
|
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.repository.MembreRoleRepository;
|
||||||
import dev.lions.unionflow.server.service.MemberLifecycleService;
|
import dev.lions.unionflow.server.service.MemberLifecycleService;
|
||||||
import dev.lions.unionflow.server.service.MembreKeycloakSyncService;
|
import dev.lions.unionflow.server.service.MembreKeycloakSyncService;
|
||||||
@@ -78,6 +79,9 @@ public class MembreResource {
|
|||||||
@Inject
|
@Inject
|
||||||
MembreOrganisationRepository membreOrgRepository;
|
MembreOrganisationRepository membreOrgRepository;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
MembreRepository membreRepository;
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
MembreRoleRepository membreRoleRepository;
|
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).
|
* Liste les membres d'une organisation spécifique (statut ACTIF dans l'organisation).
|
||||||
* Utilisé pour la création de campagnes ciblées.
|
* Utilisé pour la création de campagnes ciblées.
|
||||||
@@ -588,7 +626,7 @@ public class MembreResource {
|
|||||||
@APIResponses({
|
@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 = """
|
@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,
|
"totalElements": 247,
|
||||||
"totalPages": 13,
|
"totalPages": 13,
|
||||||
"currentPage": 0,
|
"currentPage": 0,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import jakarta.validation.Valid;
|
|||||||
import jakarta.ws.rs.*;
|
import jakarta.ws.rs.*;
|
||||||
import jakarta.ws.rs.core.MediaType;
|
import jakarta.ws.rs.core.MediaType;
|
||||||
import jakarta.ws.rs.core.Response;
|
import jakarta.ws.rs.core.Response;
|
||||||
|
import io.quarkus.security.identity.SecurityIdentity;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
@@ -24,10 +25,16 @@ public class TransactionEpargneResource {
|
|||||||
@Inject
|
@Inject
|
||||||
TransactionEpargneService transactionEpargneService;
|
TransactionEpargneService transactionEpargneService;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
SecurityIdentity securityIdentity;
|
||||||
|
|
||||||
@POST
|
@POST
|
||||||
@RolesAllowed({ "ADMIN", "SUPER_ADMIN", "ADMIN_ORGANISATION", "MUTUELLE_RESP", "MEMBRE", "USER" })
|
@RolesAllowed({ "ADMIN", "SUPER_ADMIN", "ADMIN_ORGANISATION", "MUTUELLE_RESP", "MEMBRE", "USER" })
|
||||||
public Response executerTransaction(@Valid TransactionEpargneRequest request) {
|
public Response executerTransaction(
|
||||||
TransactionEpargneResponse transaction = transactionEpargneService.executerTransaction(request);
|
@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();
|
return Response.status(Response.Status.CREATED).entity(transaction).build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -82,12 +82,15 @@ public class AlertMonitoringService {
|
|||||||
*/
|
*/
|
||||||
private void checkCpuThreshold(AlertConfiguration config) {
|
private void checkCpuThreshold(AlertConfiguration config) {
|
||||||
try {
|
try {
|
||||||
OperatingSystemMXBean osBean = ManagementFactory.getOperatingSystemMXBean();
|
// getProcessCpuLoad() renvoie la charge CPU de CE process JVM (0.0-1.0),
|
||||||
double loadAvg = osBean.getSystemLoadAverage();
|
// ce qui est correct en conteneur K8s/Docker.
|
||||||
int processors = osBean.getAvailableProcessors();
|
// getSystemLoadAverage() renvoie la charge du NODE entier (hôte Linux),
|
||||||
|
// divisée par availableProcessors() limité par le conteneur (ex: 1),
|
||||||
// Calculer l'utilisation CPU en pourcentage
|
// ce qui produit des faux positifs dès que le node est actif.
|
||||||
double cpuUsage = loadAvg < 0 ? 0.0 : Math.min(100.0, (loadAvg / processors) * 100.0);
|
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;
|
lastCpuUsage = cpuUsage;
|
||||||
|
|
||||||
int threshold = config.getCpuThresholdPercent();
|
int threshold = config.getCpuThresholdPercent();
|
||||||
|
|||||||
@@ -87,6 +87,25 @@ public class AuditService {
|
|||||||
auditLogRepository.persist(log);
|
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
|
* 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.request.*;
|
||||||
import dev.lions.unionflow.server.api.dto.comptabilite.response.*;
|
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.*;
|
||||||
|
import dev.lions.unionflow.server.entity.mutuelle.epargne.TransactionEpargne;
|
||||||
import dev.lions.unionflow.server.repository.*;
|
import dev.lions.unionflow.server.repository.*;
|
||||||
import dev.lions.unionflow.server.service.KeycloakService;
|
import dev.lions.unionflow.server.service.KeycloakService;
|
||||||
import jakarta.enterprise.context.ApplicationScoped;
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
@@ -221,6 +223,207 @@ public class ComptabiliteService {
|
|||||||
.collect(Collectors.toList());
|
.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
|
// 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.MembreRepository;
|
||||||
import dev.lions.unionflow.server.repository.OrganisationRepository;
|
import dev.lions.unionflow.server.repository.OrganisationRepository;
|
||||||
import dev.lions.unionflow.server.service.support.SecuriteHelper;
|
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.Page;
|
||||||
import io.quarkus.panache.common.Sort;
|
import io.quarkus.panache.common.Sort;
|
||||||
import jakarta.enterprise.context.ApplicationScoped;
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
@@ -43,6 +45,7 @@ import lombok.extern.slf4j.Slf4j;
|
|||||||
*/
|
*/
|
||||||
@ApplicationScoped
|
@ApplicationScoped
|
||||||
@Slf4j
|
@Slf4j
|
||||||
|
@RlsEnabled
|
||||||
public class CotisationService {
|
public class CotisationService {
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
@@ -63,6 +66,12 @@ public class CotisationService {
|
|||||||
@Inject
|
@Inject
|
||||||
OrganisationService organisationService;
|
OrganisationService organisationService;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
ComptabiliteService comptabiliteService;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
EmailTemplateService emailTemplateService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Récupère toutes les cotisations avec pagination.
|
* Récupère toutes les cotisations avec pagination.
|
||||||
*
|
*
|
||||||
@@ -246,6 +255,7 @@ public class CotisationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Déterminer le statut en fonction du montant payé
|
// Déterminer le statut en fonction du montant payé
|
||||||
|
boolean etaitDejaPayee = "PAYEE".equals(cotisation.getStatut());
|
||||||
if (cotisation.getMontantPaye() != null && cotisation.getMontantDu() != null
|
if (cotisation.getMontantPaye() != null && cotisation.getMontantDu() != null
|
||||||
&& cotisation.getMontantPaye().compareTo(cotisation.getMontantDu()) >= 0) {
|
&& cotisation.getMontantPaye().compareTo(cotisation.getMontantDu()) >= 0) {
|
||||||
cotisation.setStatut("PAYEE");
|
cotisation.setStatut("PAYEE");
|
||||||
@@ -254,6 +264,36 @@ public class CotisationService {
|
|||||||
cotisation.setStatut("PARTIELLEMENT_PAYEE");
|
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());
|
log.info("Paiement enregistré - ID: {}, Statut: {}", id, cotisation.getStatut());
|
||||||
return convertToResponse(cotisation);
|
return convertToResponse(cotisation);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,6 +89,9 @@ public class MembreKeycloakSyncService {
|
|||||||
@RestClient
|
@RestClient
|
||||||
AdminRoleServiceClient roleServiceClient;
|
AdminRoleServiceClient roleServiceClient;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
EmailTemplateService emailTemplateService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Provisionne un compte Keycloak pour un Membre existant qui n'en a pas encore.
|
* 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
|
* @param membreId UUID du membre à activer dans Keycloak
|
||||||
* @throws NotFoundException si le membre n'existe pas en base
|
* @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) {
|
public void activerMembreDansKeycloak(java.util.UUID membreId) {
|
||||||
LOGGER.info("Activation Keycloak (rôle MEMBRE_ACTIF) pour Membre ID: " + membreId);
|
LOGGER.info("Activation Keycloak (rôle MEMBRE_ACTIF) pour Membre ID: " + membreId);
|
||||||
|
|
||||||
Membre membre = membreRepository.findByIdOptional(membreId)
|
Membre membre = membreRepository.findByIdOptional(membreId)
|
||||||
.orElseThrow(() -> new NotFoundException("Membre non trouvé avec l'ID: " + 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) {
|
if (membre.getKeycloakId() == null) {
|
||||||
LOGGER.info("Compte Keycloak absent — provisionnement automatique pour " + membre.getNomComplet());
|
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);
|
provisionKeycloakUser(membreId);
|
||||||
// Recharger après persist dans provisionKeycloakUser
|
}
|
||||||
|
} 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)
|
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();
|
String keycloakUserId = membre.getKeycloakId().toString();
|
||||||
@@ -247,19 +267,36 @@ public class MembreKeycloakSyncService {
|
|||||||
* @param membreId UUID du membre à promouvoir dans Keycloak
|
* @param membreId UUID du membre à promouvoir dans Keycloak
|
||||||
* @throws NotFoundException si le membre n'existe pas en base
|
* @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) {
|
public void promouvoirAdminOrganisationDansKeycloak(java.util.UUID membreId) {
|
||||||
LOGGER.info("Promotion Keycloak (rôle ADMIN_ORGANISATION) pour Membre ID: " + membreId);
|
LOGGER.info("Promotion Keycloak (rôle ADMIN_ORGANISATION) pour Membre ID: " + membreId);
|
||||||
|
|
||||||
Membre membre = membreRepository.findByIdOptional(membreId)
|
Membre membre = membreRepository.findByIdOptional(membreId)
|
||||||
.orElseThrow(() -> new NotFoundException("Membre non trouvé avec l'ID: " + 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) {
|
if (membre.getKeycloakId() == null) {
|
||||||
LOGGER.info("Compte Keycloak absent — provisionnement automatique pour " + membre.getNomComplet());
|
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);
|
provisionKeycloakUser(membreId);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
LOGGER.warning("Recherche Keycloak par email échouée, tentative provisionnement : " + e.getMessage());
|
||||||
|
provisionKeycloakUser(membreId);
|
||||||
|
}
|
||||||
membre = membreRepository.findByIdOptional(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();
|
String keycloakUserId = membre.getKeycloakId().toString();
|
||||||
@@ -735,6 +772,28 @@ public class MembreKeycloakSyncService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
LOGGER.info("Premier login complété pour : " + membre.getEmail());
|
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;
|
return PremierLoginResultat.COMPLETE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1283,6 +1283,25 @@ public class MembreService {
|
|||||||
.getSingleResult() > 0;
|
.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.
|
* 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.
|
* Utilisé lors de la création unitaire ou de l'import massif.
|
||||||
|
|||||||
@@ -51,6 +51,9 @@ public class NotificationService {
|
|||||||
@Inject
|
@Inject
|
||||||
KeycloakService keycloakService;
|
KeycloakService keycloakService;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
FirebasePushService firebasePushService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Crée un nouveau template de notification
|
* Crée un nouveau template de notification
|
||||||
*
|
*
|
||||||
@@ -91,14 +94,19 @@ public class NotificationService {
|
|||||||
notificationRepository.persist(notification);
|
notificationRepository.persist(notification);
|
||||||
LOG.infof("Notification créée avec succès: ID=%s", notification.getId());
|
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())) {
|
if ("EMAIL".equals(notification.getTypeNotification())) {
|
||||||
try {
|
try {
|
||||||
envoyerEmail(notification);
|
envoyerEmail(notification);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
LOG.errorf("Erreur lors de l'envoi de l'email pour la notification %s: %s", notification.getId(),
|
LOG.errorf("Erreur lors de l'envoi de l'email pour la notification %s: %s", notification.getId(),
|
||||||
e.getMessage());
|
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;
|
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
|
* Envoie un email pour une notification
|
||||||
*/
|
*/
|
||||||
@@ -394,9 +434,12 @@ public class NotificationService {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
LOG.infof("Envoi de l'email à %s", notification.getMembre().getEmail());
|
LOG.infof("Envoi de l'email à %s", notification.getMembre().getEmail());
|
||||||
mailer.send(Mail.withText(notification.getMembre().getEmail(),
|
String corps = notification.getCorps();
|
||||||
notification.getSujet(),
|
boolean isHtml = corps != null && (corps.startsWith("<html") || corps.startsWith("<!DOCTYPE") || corps.startsWith("<HTML"));
|
||||||
notification.getCorps())); // TODO: Support HTML body if needed
|
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.setStatut("ENVOYEE");
|
||||||
notification.setDateEnvoi(LocalDateTime.now());
|
notification.setDateEnvoi(LocalDateTime.now());
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package dev.lions.unionflow.server.service;
|
package dev.lions.unionflow.server.service;
|
||||||
|
|
||||||
import dev.lions.unionflow.server.api.dto.paiement.request.CreatePaiementRequest;
|
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.PaiementResponse;
|
||||||
import dev.lions.unionflow.server.api.dto.paiement.response.PaiementSummaryResponse;
|
import dev.lions.unionflow.server.api.dto.paiement.response.PaiementSummaryResponse;
|
||||||
import dev.lions.unionflow.server.api.dto.reference.response.TypeReferenceResponse;
|
import dev.lions.unionflow.server.api.dto.reference.response.TypeReferenceResponse;
|
||||||
@@ -66,6 +68,12 @@ public class PaiementService {
|
|||||||
@Inject
|
@Inject
|
||||||
io.quarkus.security.identity.SecurityIdentity securityIdentity;
|
io.quarkus.security.identity.SecurityIdentity securityIdentity;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
dev.lions.unionflow.server.repository.MembreOrganisationRepository membreOrganisationRepository;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
NotificationService notificationService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Crée un nouveau paiement
|
* Crée un nouveau paiement
|
||||||
*
|
*
|
||||||
@@ -509,13 +517,30 @@ public class PaiementService {
|
|||||||
|
|
||||||
paiementRepository.persist(paiement);
|
paiementRepository.persist(paiement);
|
||||||
|
|
||||||
// TODO: Créer une notification pour le trésorier
|
// Notifier les trésoriers de l'organisation que ce paiement manuel attend validation
|
||||||
// notificationService.creerNotification(
|
try {
|
||||||
// "VALIDATION_PAIEMENT_REQUIS",
|
membreOrganisationRepository.findFirstByMembreId(membreConnecte.getId())
|
||||||
// "Validation paiement manuel requis",
|
.map(mo -> mo.getOrganisation().getId())
|
||||||
// "Le membre " + membreConnecte.getNumeroMembre() + " a déclaré un paiement manuel à valider.",
|
.ifPresent(orgId -> {
|
||||||
// tresorierIds
|
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)",
|
LOG.infof("Paiement manuel déclaré avec succès: ID=%s, Référence=%s (EN_ATTENTE_VALIDATION)",
|
||||||
paiement.getId(), paiement.getNumeroReference());
|
paiement.getId(), paiement.getNumeroReference());
|
||||||
@@ -586,6 +611,39 @@ public class PaiementService {
|
|||||||
.build();
|
.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
|
// MÉTHODES PRIVÉES
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|||||||
@@ -88,6 +88,9 @@ public class SouscriptionService {
|
|||||||
@Inject
|
@Inject
|
||||||
MembreKeycloakSyncService keycloakSyncService;
|
MembreKeycloakSyncService keycloakSyncService;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
EmailTemplateService emailTemplateService;
|
||||||
|
|
||||||
// ── Catalogue ─────────────────────────────────────────────────────────────
|
// ── Catalogue ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -302,6 +305,9 @@ public class SouscriptionService {
|
|||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
LOG.errorf("Activation compte échouée après paiement souscription=%s: %s — la souscription reste VALIDEE", souscriptionId, e.getMessage());
|
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 ──────────────────────────────────────────────────
|
// ── Validation SuperAdmin ──────────────────────────────────────────────────
|
||||||
@@ -399,6 +405,9 @@ public class SouscriptionService {
|
|||||||
// Activer le membre admin de l'organisation
|
// Activer le membre admin de l'organisation
|
||||||
activerAdminOrganisation(souscription.getOrganisation().getId());
|
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);
|
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 ────────────────────────────────────────
|
// ── 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.CompteEpargneRepository;
|
||||||
import dev.lions.unionflow.server.repository.mutuelle.epargne.TransactionEpargneRepository;
|
import dev.lions.unionflow.server.repository.mutuelle.epargne.TransactionEpargneRepository;
|
||||||
import dev.lions.unionflow.server.service.AuditService;
|
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.enterprise.context.ApplicationScoped;
|
||||||
import jakarta.inject.Inject;
|
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é.
|
* Applique les règles LCB-FT : origine des fonds obligatoire au-dessus du seuil configuré.
|
||||||
*/
|
*/
|
||||||
@ApplicationScoped
|
@ApplicationScoped
|
||||||
|
@RlsEnabled
|
||||||
public class TransactionEpargneService {
|
public class TransactionEpargneService {
|
||||||
|
|
||||||
/** Seuil LCB-FT (XOF) par défaut si aucun paramètre en base. */
|
/** Seuil LCB-FT (XOF) par défaut si aucun paramètre en base. */
|
||||||
@@ -56,6 +59,9 @@ public class TransactionEpargneService {
|
|||||||
@Inject
|
@Inject
|
||||||
dev.lions.unionflow.server.service.AlerteLcbFtService alerteLcbFtService;
|
dev.lions.unionflow.server.service.AlerteLcbFtService alerteLcbFtService;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
ComptabiliteService comptabiliteService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enregistre une nouvelle transaction et met à jour le solde du compte.
|
* Enregistre une nouvelle transaction et met à jour le solde du compte.
|
||||||
*
|
*
|
||||||
@@ -64,6 +70,11 @@ public class TransactionEpargneService {
|
|||||||
*/
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
public TransactionEpargneResponse executerTransaction(TransactionEpargneRequest request) {
|
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()))
|
CompteEpargne compte = compteEpargneRepository.findByIdOptional(UUID.fromString(request.getCompteId()))
|
||||||
.orElseThrow(() -> new NotFoundException("Compte non trouvé avec l'ID: " + request.getCompteId()));
|
.orElseThrow(() -> new NotFoundException("Compte non trouvé avec l'ID: " + request.getCompteId()));
|
||||||
|
|
||||||
@@ -85,13 +96,13 @@ public class TransactionEpargneService {
|
|||||||
soldeApres = soldeAvant.add(montant);
|
soldeApres = soldeAvant.add(montant);
|
||||||
compte.setSoldeActuel(soldeApres);
|
compte.setSoldeActuel(soldeApres);
|
||||||
} else if (isTypeDebit(request.getTypeTransaction())) {
|
} 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.");
|
throw new IllegalArgumentException("Solde disponible insuffisant pour cette opération.");
|
||||||
}
|
}
|
||||||
soldeApres = soldeAvant.subtract(montant);
|
soldeApres = soldeAvant.subtract(montant);
|
||||||
compte.setSoldeActuel(soldeApres);
|
compte.setSoldeActuel(soldeApres);
|
||||||
} else if (request.getTypeTransaction() == TypeTransactionEpargne.RETENUE_GARANTIE) {
|
} 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.");
|
throw new IllegalArgumentException("Solde disponible insuffisant pour geler ce montant.");
|
||||||
}
|
}
|
||||||
compte.setSoldeBloque(compte.getSoldeBloque().add(montant));
|
compte.setSoldeBloque(compte.getSoldeBloque().add(montant));
|
||||||
@@ -125,6 +136,19 @@ public class TransactionEpargneService {
|
|||||||
|
|
||||||
transactionEpargneRepository.persist(transaction);
|
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) {
|
if (request.getMontant() != null && request.getMontant().compareTo(seuil) >= 0) {
|
||||||
UUID orgId = compte.getOrganisation() != null ? compte.getOrganisation().getId() : null;
|
UUID orgId = compte.getOrganisation() != null ? compte.getOrganisation().getId() : null;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user