package dev.lions.unionflow.server.service; import dev.lions.unionflow.server.api.dto.notification.request.CreateNotificationRequest; import dev.lions.unionflow.server.api.dto.versement.request.DeclarerVersementManuelRequest; import dev.lions.unionflow.server.api.dto.versement.request.InitierDepotEpargneRequest; import dev.lions.unionflow.server.api.dto.versement.request.InitierVersementWaveRequest; import dev.lions.unionflow.server.api.dto.versement.response.VersementGatewayResponse; import dev.lions.unionflow.server.api.dto.versement.response.VersementResponse; import dev.lions.unionflow.server.api.dto.versement.response.VersementStatutResponse; import dev.lions.unionflow.server.api.dto.versement.response.VersementSummaryResponse; import dev.lions.unionflow.server.api.enums.paiement.StatutIntentionPaiement; import dev.lions.unionflow.server.api.enums.paiement.TypeObjetIntentionPaiement; import dev.lions.unionflow.server.entity.Cotisation; import dev.lions.unionflow.server.entity.IntentionPaiement; import dev.lions.unionflow.server.entity.Membre; import dev.lions.unionflow.server.entity.Versement; import dev.lions.unionflow.server.entity.mutuelle.epargne.CompteEpargne; import dev.lions.unionflow.server.repository.IntentionPaiementRepository; import dev.lions.unionflow.server.repository.MembreOrganisationRepository; import dev.lions.unionflow.server.repository.MembreRepository; import dev.lions.unionflow.server.repository.TypeReferenceRepository; import dev.lions.unionflow.server.repository.VersementRepository; import dev.lions.unionflow.server.repository.mutuelle.epargne.CompteEpargneRepository; import dev.lions.unionflow.server.service.WaveCheckoutService.WaveCheckoutException; import dev.lions.unionflow.server.service.WaveCheckoutService.WaveCheckoutSessionResponse; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import jakarta.transaction.Transactional; import jakarta.ws.rs.NotFoundException; import java.math.BigDecimal; import java.time.LocalDateTime; import java.util.List; import java.util.UUID; import java.util.stream.Collectors; import org.jboss.logging.Logger; /** * Service métier pour la gestion des versements. * *

Un versement est l'acte de régler une cotisation. Deux flux sont supportés : *

    *
  1. Wave : deep link natif, app Wave sur le même téléphone, retour via * {@code unionflow://payment?result=success&ref={intentionId}}
  2. *
  3. Manuel : déclaration espèces/virement/chèque → validation trésorier
  4. *
* * @author UnionFlow Team * @version 4.0 * @since 2026-04-13 */ @ApplicationScoped public class VersementService { private static final Logger LOG = Logger.getLogger(VersementService.class); @Inject VersementRepository versementRepository; @Inject MembreRepository membreRepository; @Inject KeycloakService keycloakService; @Inject TypeReferenceRepository typeReferenceRepository; @Inject IntentionPaiementRepository intentionPaiementRepository; @Inject WaveCheckoutService waveCheckoutService; @Inject CompteEpargneRepository compteEpargneRepository; @Inject MembreOrganisationRepository membreOrganisationRepository; @Inject NotificationService notificationService; @Inject io.quarkus.security.identity.SecurityIdentity securityIdentity; // ── Lecture ─────────────────────────────────────────────────────────────── public VersementResponse trouverParId(UUID id) { return versementRepository.findVersementById(id) .map(this::convertToResponse) .orElseThrow(() -> new NotFoundException("Versement non trouvé : " + id)); } public VersementResponse trouverParNumeroReference(String numeroReference) { return versementRepository.findByNumeroReference(numeroReference) .map(this::convertToResponse) .orElseThrow(() -> new NotFoundException("Versement non trouvé : " + numeroReference)); } public List listerParMembre(UUID membreId) { return versementRepository.findByMembreId(membreId).stream() .map(this::convertToSummaryResponse) .collect(Collectors.toList()); } public BigDecimal calculerMontantTotalConfirmes(LocalDateTime dateDebut, LocalDateTime dateFin) { return versementRepository.calculerMontantTotalConfirmes(dateDebut, dateFin); } /** * Historique des versements du membre connecté (auto-détection). * Retourne uniquement les versements confirmés/validés. */ public List getMesVersements(int limit) { Membre membreConnecte = getMembreConnecte(); LOG.infof("Historique versements pour %s, limit=%d", membreConnecte.getNumeroMembre(), limit); List versements = versementRepository.getEntityManager() .createQuery( "SELECT v FROM Versement v " + "WHERE v.membre.id = :membreId " + "AND v.statutPaiement IN ('CONFIRME', 'VALIDE') " + "ORDER BY v.datePaiement DESC", Versement.class) .setParameter("membreId", membreConnecte.getId()) .setMaxResults(limit) .getResultList(); return versements.stream() .map(this::convertToSummaryResponse) .collect(Collectors.toList()); } // ── Validation / Annulation ─────────────────────────────────────────────── @Transactional public VersementResponse validerVersement(UUID id) { Versement versement = versementRepository.findVersementById(id) .orElseThrow(() -> new NotFoundException("Versement non trouvé : " + id)); if (versement.isConfirme()) { return convertToResponse(versement); } versement.setStatutPaiement("CONFIRME"); versement.setDateValidation(LocalDateTime.now()); versement.setValidateur(keycloakService.getCurrentUserEmail()); versement.setModifiePar(keycloakService.getCurrentUserEmail()); versementRepository.persist(versement); LOG.infof("Versement validé : %s", id); return convertToResponse(versement); } @Transactional public VersementResponse annulerVersement(UUID id) { Versement versement = versementRepository.findVersementById(id) .orElseThrow(() -> new NotFoundException("Versement non trouvé : " + id)); if (!versement.peutEtreModifie()) { throw new IllegalStateException("Le versement ne peut plus être annulé (statut finalisé)"); } versement.setStatutPaiement("ANNULE"); versement.setModifiePar(keycloakService.getCurrentUserEmail()); versementRepository.persist(versement); LOG.infof("Versement annulé : %s", id); return convertToResponse(versement); } // ── Flux Wave ───────────────────────────────────────────────────────────── /** * Initie un versement Wave. * *
    *
  1. Crée une {@link IntentionPaiement} (hub Wave interne)
  2. *
  3. Appelle l'API Wave Checkout → obtient {@code waveLaunchUrl}
  4. *
  5. Retourne {@link VersementGatewayResponse} avec {@code waveLaunchUrl} * pour que {@code url_launcher} ouvre Wave directement
  6. *
*/ @Transactional public VersementGatewayResponse initierVersementWave(InitierVersementWaveRequest request) { Membre membreConnecte = getMembreConnecte(); LOG.infof("Initiation versement Wave — membre %s, cotisation %s", membreConnecte.getNumeroMembre(), request.cotisationId()); Cotisation cotisation = versementRepository.getEntityManager() .find(Cotisation.class, request.cotisationId()); if (cotisation == null) { throw new NotFoundException("Cotisation non trouvée : " + request.cotisationId()); } if (!cotisation.getMembre().getId().equals(membreConnecte.getId())) { throw new IllegalArgumentException("Cette cotisation n'appartient pas au membre connecté"); } String base = waveCheckoutService.getRedirectBaseUrl().replaceAll("/+$", ""); // 1. Créer l'intention (détail interne Wave — non exposé dans l'API publique) IntentionPaiement intention = IntentionPaiement.builder() .utilisateur(membreConnecte) .organisation(cotisation.getOrganisation()) .montantTotal(cotisation.getMontantDu()) .codeDevise(cotisation.getCodeDevise() != null ? cotisation.getCodeDevise() : "XOF") .typeObjet(TypeObjetIntentionPaiement.COTISATION) .statut(StatutIntentionPaiement.INITIEE) .objetsCibles("[{\"type\":\"COTISATION\",\"id\":\"" + cotisation.getId() + "\",\"montant\":" + cotisation.getMontantDu() + "}]") .build(); intentionPaiementRepository.persist(intention); // 2. URL success → deep link si mobile, page HTML si web boolean isMobile = request.numeroTelephone() != null && !request.numeroTelephone().isBlank(); String successUrl = base + (isMobile ? "/api/wave-redirect/success?ref=" + intention.getId() : "/api/wave-redirect/web-success?ref=" + intention.getId()); String errorUrl = base + "/api/wave-redirect/error?ref=" + intention.getId(); String amountStr = cotisation.getMontantDu() .setScale(0, java.math.RoundingMode.HALF_UP).toString(); String restrictMobile = toE164(request.numeroTelephone()); // 3. Appel Wave Checkout API WaveCheckoutSessionResponse session; try { session = waveCheckoutService.createSession( amountStr, "XOF", successUrl, errorUrl, intention.getId().toString(), restrictMobile); } catch (WaveCheckoutException e) { LOG.errorf(e, "Wave Checkout API error : %s", e.getMessage()); intention.setStatut(StatutIntentionPaiement.ECHOUEE); intentionPaiementRepository.persist(intention); throw new jakarta.ws.rs.BadRequestException("Wave : " + e.getMessage()); } intention.setWaveCheckoutSessionId(session.id); intention.setWaveLaunchUrl(session.waveLaunchUrl); intention.setStatut(StatutIntentionPaiement.EN_COURS); intentionPaiementRepository.persist(intention); cotisation.setIntentionPaiement(intention); versementRepository.getEntityManager().merge(cotisation); // 4. Créer le versement en EN_ATTENTE Versement versement = new Versement(); versement.setNumeroReference("VRS-WAVE-" + intention.getId().toString().substring(0, 8).toUpperCase()); versement.setMontant(cotisation.getMontantDu()); versement.setCodeDevise("XOF"); versement.setMethodePaiement("WAVE"); versement.setStatutPaiement("EN_ATTENTE"); versement.setMembre(membreConnecte); versement.setReferenceExterne(session.id); versement.setNumeroTelephone(request.numeroTelephone()); versement.setCommentaire("Versement Wave — session " + session.id); versement.setCreePar(membreConnecte.getEmail()); versementRepository.persist(versement); LOG.infof("Versement Wave initié : intention=%s, session=%s, waveLaunchUrl=%s", intention.getId(), session.id, session.waveLaunchUrl); return VersementGatewayResponse.builder() .versementId(versement.getId()) .waveLaunchUrl(session.waveLaunchUrl) .waveCheckoutSessionId(session.id) .clientReference(intention.getId().toString()) .montant(cotisation.getMontantDu()) .statut("EN_ATTENTE") .referenceCotisation(cotisation.getNumeroReference()) .message("Ouvrez Wave pour confirmer le versement, puis vous serez renvoyé dans UnionFlow.") .build(); } // ── Flux dépôt épargne ──────────────────────────────────────────────────── @Transactional public VersementGatewayResponse initierDepotEpargneEnLigne(InitierDepotEpargneRequest request) { Membre membreConnecte = getMembreConnecte(); CompteEpargne compte = compteEpargneRepository.findByIdOptional(request.compteId()) .orElseThrow(() -> new NotFoundException("Compte épargne non trouvé : " + request.compteId())); if (!compte.getMembre().getId().equals(membreConnecte.getId())) { throw new IllegalArgumentException("Ce compte épargne n'appartient pas au membre connecté"); } String base = waveCheckoutService.getRedirectBaseUrl().replaceAll("/+$", ""); BigDecimal montant = request.montant().setScale(0, java.math.RoundingMode.HALF_UP); String objetsCibles = "[{\"type\":\"DEPOT_EPARGNE\",\"compteId\":\"" + request.compteId() + "\",\"montant\":" + montant + "}]"; IntentionPaiement intention = IntentionPaiement.builder() .utilisateur(membreConnecte) .organisation(compte.getOrganisation()) .montantTotal(montant) .codeDevise("XOF") .typeObjet(TypeObjetIntentionPaiement.DEPOT_EPARGNE) .statut(StatutIntentionPaiement.INITIEE) .objetsCibles(objetsCibles) .build(); intentionPaiementRepository.persist(intention); String successUrl = base + "/api/wave-redirect/success?ref=" + intention.getId(); String errorUrl = base + "/api/wave-redirect/error?ref=" + intention.getId(); WaveCheckoutSessionResponse session; try { session = waveCheckoutService.createSession( montant.toString(), "XOF", successUrl, errorUrl, intention.getId().toString(), toE164(request.numeroTelephone())); } catch (WaveCheckoutException e) { LOG.errorf(e, "Wave Checkout (dépôt épargne) : %s", e.getMessage()); intention.setStatut(StatutIntentionPaiement.ECHOUEE); intentionPaiementRepository.persist(intention); throw new jakarta.ws.rs.BadRequestException("Wave : " + e.getMessage()); } intention.setWaveCheckoutSessionId(session.id); intention.setWaveLaunchUrl(session.waveLaunchUrl); intention.setStatut(StatutIntentionPaiement.EN_COURS); intentionPaiementRepository.persist(intention); LOG.infof("Dépôt épargne Wave initié : intention=%s, compte=%s", intention.getId(), request.compteId()); return VersementGatewayResponse.builder() .versementId(intention.getId()) .waveLaunchUrl(session.waveLaunchUrl) .waveCheckoutSessionId(session.id) .clientReference(intention.getId().toString()) .montant(montant) .statut("EN_ATTENTE") .message("Ouvrez Wave pour confirmer le dépôt, puis vous serez renvoyé dans UnionFlow.") .build(); } // ── Polling statut ──────────────────────────────────────────────────────── /** * Vérifie le statut d'une intention Wave. * Utilisé par le deep link de retour (mobile) et le polling web. */ @Transactional public VersementStatutResponse verifierStatutVersement(UUID intentionId) { IntentionPaiement intention = intentionPaiementRepository.findById(intentionId); if (intention == null) { throw new NotFoundException("Intention non trouvée : " + intentionId); } if (intention.isCompletee()) { return buildStatutResponse(intention, "Versement confirmé !"); } if (StatutIntentionPaiement.EXPIREE.equals(intention.getStatut()) || StatutIntentionPaiement.ECHOUEE.equals(intention.getStatut())) { return buildStatutResponse(intention, "Versement " + intention.getStatut().name().toLowerCase()); } if (intention.isExpiree()) { intention.setStatut(StatutIntentionPaiement.EXPIREE); intentionPaiementRepository.persist(intention); return buildStatutResponse(intention, "Session expirée, veuillez recommencer"); } if (intention.getWaveCheckoutSessionId() != null) { try { WaveCheckoutService.WaveSessionStatusResponse waveStatus = waveCheckoutService.getSession(intention.getWaveCheckoutSessionId()); if (waveStatus.isSucceeded()) { confirmerVersementWave(intention, waveStatus.transactionId); return buildStatutResponse(intention, "Versement confirmé !"); } else if (waveStatus.isExpired()) { intention.setStatut(StatutIntentionPaiement.EXPIREE); intentionPaiementRepository.persist(intention); return buildStatutResponse(intention, "Session Wave expirée"); } } catch (WaveCheckoutService.WaveCheckoutException e) { LOG.warnf(e, "Impossible de vérifier la session Wave %s — retry au prochain appel", intention.getWaveCheckoutSessionId()); } } return buildStatutResponse(intention, "En attente de confirmation Wave..."); } // ── Flux manuel ─────────────────────────────────────────────────────────── @Transactional public VersementResponse declarerVersementManuel(DeclarerVersementManuelRequest request) { Membre membreConnecte = getMembreConnecte(); LOG.infof("Déclaration versement manuel — membre %s, cotisation %s, méthode %s", membreConnecte.getNumeroMembre(), request.cotisationId(), request.methodePaiement()); Cotisation cotisation = versementRepository.getEntityManager() .createQuery("SELECT c FROM Cotisation c WHERE c.id = :id", Cotisation.class) .setParameter("id", request.cotisationId()) .getResultList().stream().findFirst() .orElseThrow(() -> new NotFoundException("Cotisation non trouvée : " + request.cotisationId())); if (!cotisation.getMembre().getId().equals(membreConnecte.getId())) { throw new IllegalArgumentException("Cette cotisation n'appartient pas au membre connecté"); } Versement versement = new Versement(); versement.setNumeroReference("VRS-MAN-" + UUID.randomUUID().toString().substring(0, 8).toUpperCase()); versement.setMontant(cotisation.getMontantDu()); versement.setCodeDevise("XOF"); versement.setMethodePaiement(request.methodePaiement()); versement.setStatutPaiement("EN_ATTENTE_VALIDATION"); versement.setMembre(membreConnecte); versement.setReferenceExterne(request.reference()); versement.setCommentaire(request.commentaire()); versement.setDatePaiement(LocalDateTime.now()); versement.setCreePar(membreConnecte.getEmail()); versementRepository.persist(versement); // Notifier le trésorier membreOrganisationRepository.findFirstByMembreId(membreConnecte.getId()) .ifPresent(mo -> { CreateNotificationRequest notif = CreateNotificationRequest.builder() .typeNotification("VALIDATION_VERSEMENT_REQUIS") .priorite("HAUTE") .sujet("Validation versement manuel requis") .corps("Le membre " + membreConnecte.getNumeroMembre() + " a déclaré un versement manuel de " + versement.getMontant() + " XOF (réf: " + versement.getNumeroReference() + ") à valider.") .organisationId(mo.getOrganisation().getId()) .build(); notificationService.creerNotification(notif); }); LOG.infof("Versement manuel déclaré : %s (EN_ATTENTE_VALIDATION)", versement.getNumeroReference()); return convertToResponse(versement); } // ── Réconciliation Wave (appelé par WaveRedirectResource) ───────────────── /** * Marque l'intention COMPLETEE et met à jour les cotisations cibles. * Idempotent : si déjà complétée, retourne sans effet. */ @Transactional public void confirmerVersementWave(IntentionPaiement intention, String waveTransactionId) { if (intention.isCompletee()) return; intention.setStatut(StatutIntentionPaiement.COMPLETEE); intention.setDateCompletion(LocalDateTime.now()); if (waveTransactionId != null) { intention.setWaveTransactionId(waveTransactionId); } intentionPaiementRepository.persist(intention); String objetsCibles = intention.getObjetsCibles(); if (objetsCibles == null || objetsCibles.isBlank()) return; try { com.fasterxml.jackson.databind.JsonNode arr = new com.fasterxml.jackson.databind.ObjectMapper().readTree(objetsCibles); if (!arr.isArray()) return; for (com.fasterxml.jackson.databind.JsonNode node : arr) { if (!"COTISATION".equals(node.path("type").asText())) continue; UUID cotisationId = UUID.fromString(node.get("id").asText()); BigDecimal montant = node.has("montant") ? new BigDecimal(node.get("montant").asText()) : intention.getMontantTotal(); Cotisation cotisation = versementRepository.getEntityManager() .find(Cotisation.class, cotisationId); if (cotisation == null) continue; cotisation.setMontantPaye(montant); cotisation.setStatut("PAYEE"); cotisation.setDatePaiement(LocalDateTime.now()); versementRepository.getEntityManager().merge(cotisation); LOG.infof("Cotisation %s marquée PAYEE — Wave txn %s", cotisationId, waveTransactionId); } } catch (Exception e) { LOG.errorf(e, "Erreur réconciliation cotisations pour intention %s", intention.getId()); } } // ── Méthodes privées ────────────────────────────────────────────────────── private Membre getMembreConnecte() { String email = securityIdentity.getPrincipal().getName(); return membreRepository.findByEmail(email) .orElseThrow(() -> new NotFoundException( "Membre non trouvé pour l'email : " + email)); } private VersementResponse convertToResponse(Versement v) { VersementResponse r = new VersementResponse(); r.setId(v.getId()); r.setNumeroReference(v.getNumeroReference()); r.setMontant(v.getMontant()); r.setCodeDevise(v.getCodeDevise()); r.setMethodePaiement(v.getMethodePaiement()); r.setStatutPaiement(v.getStatutPaiement()); r.setDatePaiement(v.getDatePaiement()); r.setDateValidation(v.getDateValidation()); r.setValidateur(v.getValidateur()); r.setReferenceExterne(v.getReferenceExterne()); r.setUrlPreuve(v.getUrlPreuve()); r.setCommentaire(v.getCommentaire()); r.setNumeroTelephone(v.getNumeroTelephone()); if (v.getMembre() != null) { r.setMembreId(v.getMembre().getId()); } if (v.getTransactionWave() != null) { r.setTransactionWaveId(v.getTransactionWave().getId()); } r.setDateCreation(v.getDateCreation()); r.setDateModification(v.getDateModification()); r.setActif(v.getActif()); enrichirLibelles(v, r); return r; } private VersementSummaryResponse convertToSummaryResponse(Versement v) { if (v == null) return null; return new VersementSummaryResponse( v.getId(), v.getNumeroReference(), v.getMontant(), v.getCodeDevise(), resolveLibelle("METHODE_PAIEMENT", v.getMethodePaiement()), v.getStatutPaiement(), resolveLibelle("STATUT_PAIEMENT", v.getStatutPaiement()), resolveSeverity("STATUT_PAIEMENT", v.getStatutPaiement()), v.getDatePaiement()); } private void enrichirLibelles(Versement v, VersementResponse r) { r.setMethodePaiementLibelle(resolveLibelle("METHODE_PAIEMENT", v.getMethodePaiement())); r.setStatutPaiementLibelle(resolveLibelle("STATUT_PAIEMENT", v.getStatutPaiement())); r.setStatutPaiementSeverity(resolveSeverity("STATUT_PAIEMENT", v.getStatutPaiement())); } private String resolveLibelle(String domaine, String code) { if (code == null) return null; return typeReferenceRepository.findByDomaineAndCode(domaine, code) .map(dev.lions.unionflow.server.entity.TypeReference::getLibelle) .orElse(code); } private String resolveSeverity(String domaine, String code) { if (code == null) return null; return typeReferenceRepository.findByDomaineAndCode(domaine, code) .map(dev.lions.unionflow.server.entity.TypeReference::getSeverity) .orElse("info"); } private VersementStatutResponse buildStatutResponse(IntentionPaiement intention, String message) { return VersementStatutResponse.builder() .intentionId(intention.getId()) .statut(intention.getStatut().name()) .confirme(intention.isCompletee()) .waveLaunchUrl(intention.getWaveLaunchUrl()) .waveCheckoutSessionId(intention.getWaveCheckoutSessionId()) .waveTransactionId(intention.getWaveTransactionId()) .montant(intention.getMontantTotal()) .message(message) .build(); } /** Format E.164 pour Wave : 771234567 → +221771234567 */ static String toE164(String numeroTelephone) { if (numeroTelephone == null || numeroTelephone.isBlank()) return null; String digits = numeroTelephone.replaceAll("\\D", ""); if (digits.length() == 9 && (digits.startsWith("7") || digits.startsWith("0"))) { return "+221" + (digits.startsWith("0") ? digits.substring(1) : digits); } if (digits.length() >= 9 && digits.startsWith("221")) return "+" + digits; return numeroTelephone.startsWith("+") ? numeroTelephone : "+" + digits; } }