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 :
*
* - Wave : deep link natif, app Wave sur le même téléphone, retour via
* {@code unionflow://payment?result=success&ref={intentionId}}
* - Manuel : déclaration espèces/virement/chèque → validation trésorier
*
*
* @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.
*
*
* - Crée une {@link IntentionPaiement} (hub Wave interne)
* - Appelle l'API Wave Checkout → obtient {@code waveLaunchUrl}
* - Retourne {@link VersementGatewayResponse} avec {@code waveLaunchUrl}
* pour que {@code url_launcher} ouvre Wave directement
*
*/
@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;
}
}