fix(disaster-recovery 2/2): restaurer 242 fichiers Java modifiés par a72ab54
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:
2026-04-25 01:05:08 +00:00
parent 044ca4bd7e
commit 6e9841b3bb
242 changed files with 38000 additions and 37312 deletions

View File

@@ -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)

View File

@@ -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;
}

View File

@@ -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)

View File

@@ -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;

View File

@@ -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;

View File

@@ -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) {

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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.
*/

View File

@@ -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());

View File

@@ -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,

View File

@@ -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();
}

View File

@@ -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();

View File

@@ -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
*/

View File

@@ -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
// ========================================

View File

@@ -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);
}

View File

@@ -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());
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);
// 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)
.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());
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;
}

View File

@@ -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.

View File

@@ -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());

View File

@@ -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
// ========================================

View File

@@ -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 ────────────────────────────────────────
/**

View File

@@ -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;