package dev.lions.unionflow.server.service; import dev.lions.unionflow.server.api.dto.souscription.FormuleAbonnementResponse; import dev.lions.unionflow.server.api.dto.souscription.SouscriptionDemandeRequest; import dev.lions.unionflow.server.api.dto.souscription.SouscriptionStatutResponse; import dev.lions.unionflow.server.api.enums.abonnement.PlageMembres; import dev.lions.unionflow.server.api.enums.abonnement.StatutSouscription; import dev.lions.unionflow.server.api.enums.abonnement.StatutValidationSouscription; import dev.lions.unionflow.server.api.enums.abonnement.TypeFormule; import dev.lions.unionflow.server.api.enums.abonnement.TypePeriodeAbonnement; import dev.lions.unionflow.server.api.enums.abonnement.TypeOrganisationFacturation; import dev.lions.unionflow.server.entity.FormuleAbonnement; import dev.lions.unionflow.server.entity.Membre; import dev.lions.unionflow.server.entity.Organisation; import dev.lions.unionflow.server.entity.SouscriptionOrganisation; import dev.lions.unionflow.server.repository.FormuleAbonnementRepository; import dev.lions.unionflow.server.repository.MembreOrganisationRepository; import dev.lions.unionflow.server.repository.MembreRepository; import dev.lions.unionflow.server.repository.OrganisationRepository; import dev.lions.unionflow.server.repository.SouscriptionOrganisationRepository; import dev.lions.unionflow.server.service.support.SecuriteHelper; import dev.lions.unionflow.server.service.MembreKeycloakSyncService; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import jakarta.transaction.Transactional; import jakarta.ws.rs.BadRequestException; import jakarta.ws.rs.NotFoundException; import org.jboss.logging.Logger; import java.math.BigDecimal; import java.math.RoundingMode; import java.time.LocalDate; import java.util.List; import java.util.UUID; import java.util.stream.Collectors; /** * Service orchestrant le workflow de souscription/onboarding UnionFlow. * *
Cycle de vie d'une souscription : *
* creerDemande() → EN_ATTENTE_PAIEMENT * initierPaiementWave() → PAIEMENT_INITIE (session Wave ouverte) * confirmerPaiement() → PAIEMENT_CONFIRME (en attente SuperAdmin) * approuver() → VALIDEE + activation du compte ADMIN_ORGANISATION * rejeter() → REJETEE ** * @author UnionFlow Team * @version 1.0 * @since 2026-03-30 */ @ApplicationScoped public class SouscriptionService { private static final Logger LOG = Logger.getLogger(SouscriptionService.class); /** Prix de base XOF/mois par (TypeFormule, PlageMembres). */ private static final java.util.Map
Endpoint public — PermitAll.
*/
public List Calcule le montant total selon la matrice tarifaire :
* {@code montantTotal = prixBase × coeffOrg × coeffPeriode × nombreMois}
*
* @param request données de la demande (formule, plage, période, type org, orgId)
* @return réponse avec le statut EN_ATTENTE_PAIEMENT et le montant calculé
* @throws NotFoundException si l'organisation ou la formule n'existent pas
* @throws BadRequestException si une souscription active existe déjà pour l'org
*/
@Transactional
public SouscriptionStatutResponse creerDemande(SouscriptionDemandeRequest request) {
LOG.infof("Création demande souscription — org=%s formule=%s plage=%s période=%s type=%s",
request.getOrganisationId(), request.getTypeFormule(),
request.getPlageMembres(), request.getTypePeriode(), request.getTypeOrganisation());
UUID orgId = parseUuid(request.getOrganisationId(), "organisationId");
Organisation org = organisationRepo.findByIdOptional(orgId)
.orElseThrow(() -> new NotFoundException("Organisation introuvable: " + orgId));
// Vérifier qu'il n'existe pas déjà une souscription non-rejetée pour cette org
souscriptionRepo.find(
"organisation.id = ?1 and statutValidation != ?2",
orgId, StatutValidationSouscription.REJETEE)
.firstResultOptional()
.ifPresent(s -> {
throw new BadRequestException(
"Une souscription en cours existe déjà pour cette organisation (statut: "
+ s.getStatutValidation() + ")");
});
TypeFormule typeFormule = parseEnum(TypeFormule.class, request.getTypeFormule(), "typeFormule");
PlageMembres plage = parseEnum(PlageMembres.class, request.getPlageMembres(), "plageMembres");
TypePeriodeAbonnement periode = parseEnum(TypePeriodeAbonnement.class, request.getTypePeriode(), "typePeriode");
// typeOrganisation est optionnel : si absent, on le dérive depuis l'entité Organisation
String typeOrgStr = (request.getTypeOrganisation() != null && !request.getTypeOrganisation().isBlank())
? request.getTypeOrganisation()
: (org.getTypeOrganisation() != null ? org.getTypeOrganisation() : "ASSOCIATION");
TypeOrganisationFacturation typeOrg = parseEnum(TypeOrganisationFacturation.class, typeOrgStr, "typeOrganisation");
FormuleAbonnement formule = formuleRepo.findByCodeAndPlage(typeFormule, plage)
.orElseThrow(() -> new NotFoundException(
"Formule introuvable pour code=" + typeFormule + " et plage=" + plage));
// Calcul du montant total
BigDecimal prixBase = formule.getPrixMensuel();
BigDecimal coeffOrg = typeOrg.getCoefficient(typeFormule.name());
BigDecimal coeffPeriode = periode.getCoefficient();
int nombreMois = periode.getNombreMois();
BigDecimal coeffTotal = coeffOrg.multiply(coeffPeriode);
BigDecimal montantTotal = prixBase
.multiply(coeffTotal)
.multiply(BigDecimal.valueOf(nombreMois))
.setScale(0, RoundingMode.HALF_UP);
LOG.debugf("Calcul: prixBase=%s × coeffOrg=%s × coeffPériode=%s × %d mois = %s XOF",
prixBase, coeffOrg, coeffPeriode, nombreMois, montantTotal);
LocalDate dateDebut = LocalDate.now();
LocalDate dateFin = dateDebut.plusMonths(nombreMois);
SouscriptionOrganisation souscription = SouscriptionOrganisation.builder()
.organisation(org)
.formule(formule)
.typePeriode(periode)
.plage(plage)
.typeOrganisationSouscription(typeOrg)
.coefficientApplique(coeffTotal)
.montantTotal(montantTotal)
.statutValidation(StatutValidationSouscription.EN_ATTENTE_PAIEMENT)
.statut(StatutSouscription.EN_ATTENTE)
.dateDebut(dateDebut)
.dateFin(dateFin)
.quotaMax(formule.getMaxMembres())
.build();
souscriptionRepo.persist(souscription);
LOG.infof("Souscription créée id=%s montant=%s XOF", souscription.getId(), montantTotal);
return toStatutResponse(souscription, null);
}
// ── Consultation ──────────────────────────────────────────────────────────
/**
* Retourne la souscription de l'organisation du membre connecté.
*/
public SouscriptionStatutResponse getMaSouscription() {
UUID membreId = securiteHelper.resolveMembreId();
Membre membre = membreRepository.findByIdOptional(membreId)
.orElseThrow(() -> new NotFoundException("Membre introuvable"));
// Trouver l'organisation du membre (on prend la première organisation admin trouvée)
SouscriptionOrganisation souscription = souscriptionRepo
.find("organisation.id IN (SELECT mo.organisation.id FROM MembreOrganisation mo WHERE mo.membre.id = ?1) ORDER BY dateCreation DESC",
membreId)
.firstResultOptional()
.orElseThrow(() -> new NotFoundException("Aucune souscription trouvée pour ce membre"));
return toStatutResponse(souscription, null);
}
/**
* Retourne une souscription par son ID (usage interne / admin).
*/
public SouscriptionStatutResponse getSouscription(UUID souscriptionId) {
SouscriptionOrganisation s = findSouscription(souscriptionId);
return toStatutResponse(s, null);
}
// ── Paiement Wave ──────────────────────────────────────────────────────────
/**
* Initie une session de paiement Wave pour une souscription en attente.
*
* @param souscriptionId UUID de la souscription
* @return réponse avec le statut PAIEMENT_INITIE et le waveLaunchUrl
*/
@Transactional
public SouscriptionStatutResponse initierPaiementWave(UUID souscriptionId) {
LOG.infof("Initiation paiement Wave — souscriptionId=%s", souscriptionId);
SouscriptionOrganisation souscription = findSouscription(souscriptionId);
if (!souscription.getStatutValidation().peutInitierPaiement()) {
throw new BadRequestException("Impossible d'initier le paiement depuis le statut: "
+ souscription.getStatutValidation());
}
BigDecimal montant = souscription.getMontantTotal();
if (montant == null || montant.compareTo(BigDecimal.ZERO) <= 0) {
throw new BadRequestException("Montant de souscription invalide: " + montant);
}
// Wave attend le montant en string sans décimales pour XOF
String amountStr = montant.setScale(0, RoundingMode.HALF_UP).toPlainString();
String clientRef = "SOUSCRIPTION-" + souscriptionId;
String successUrl = "unionflow://payment/success?id=" + souscriptionId;
String errorUrl = "unionflow://payment/error?id=" + souscriptionId;
WaveCheckoutService.WaveCheckoutSessionResponse session;
try {
session = waveService.createSession(amountStr, "XOF", successUrl, errorUrl, clientRef, null);
} catch (WaveCheckoutService.WaveCheckoutException e) {
LOG.errorf("Erreur Wave Checkout pour souscription %s: %s", souscriptionId, e.getMessage());
throw new BadRequestException("Erreur de création de session Wave: " + e.getMessage());
}
souscription.setWaveSessionId(session.id);
souscription.setWaveCheckoutUrl(session.waveLaunchUrl);
souscription.setStatutValidation(StatutValidationSouscription.PAIEMENT_INITIE);
souscriptionRepo.persist(souscription);
LOG.infof("Session Wave créée id=%s pour souscription=%s", session.id, souscriptionId);
return toStatutResponse(souscription, session.waveLaunchUrl);
}
/**
* Confirme la réception d'un paiement Wave (appelé depuis le deep link ou webhook).
*
* Passe la souscription en PAIEMENT_CONFIRME et notifie les SuperAdmins.
*
* @param souscriptionId UUID de la souscription
* @param waveRef identifiant de transaction Wave retourné par le deep link
*/
@Transactional
public void confirmerPaiement(UUID souscriptionId, String waveRef) {
LOG.infof("Confirmation paiement — souscriptionId=%s waveRef=%s", souscriptionId, waveRef);
SouscriptionOrganisation souscription = findSouscription(souscriptionId);
if (souscription.getStatutValidation() != StatutValidationSouscription.PAIEMENT_INITIE
&& souscription.getStatutValidation() != StatutValidationSouscription.EN_ATTENTE_PAIEMENT) {
throw new BadRequestException("Impossible de confirmer depuis le statut: "
+ souscription.getStatutValidation());
}
LocalDate dateDebut = LocalDate.now();
LocalDate dateFin = dateDebut.plusMonths(souscription.getTypePeriode().getNombreMois());
souscription.setReferencePaiementWave(waveRef);
souscription.setStatutValidation(StatutValidationSouscription.VALIDEE);
souscription.setStatut(StatutSouscription.ACTIVE);
souscription.setDateDernierPaiement(dateDebut);
souscription.setDateDebut(dateDebut);
souscription.setDateFin(dateFin);
souscription.setDateProchainePaiement(dateFin);
souscriptionRepo.persist(souscription);
// Auto-activation du compte admin de l'organisation (non-bloquant : la souscription est déjà commitée)
try {
activerAdminOrganisation(souscription.getOrganisation().getId());
LOG.infof("Paiement confirmé et compte activé pour souscription=%s", souscriptionId);
} catch (Exception e) {
LOG.errorf("Activation compte échouée après paiement souscription=%s: %s — la souscription reste VALIDEE", souscriptionId, e.getMessage());
}
}
// ── Validation SuperAdmin ──────────────────────────────────────────────────
/**
* Liste toutes les souscriptions (SuperAdmin), avec filtre optionnel par organisation.
*/
public List Actions effectuées :
* Stratégie : identifier le membre qui a payé (via JWT) et le promouvoir
* ORGADMIN. Si le membre n'a pas de lien MembreOrganisation, le créer.
*
* Cascades : promouvoirAdminOrganisation (DB) + sync Keycloak ADMIN_ORGANISATION.
*/
private void activerAdminOrganisation(UUID organisationId) {
// ── 1. Identifier le caller (le membre qui a payé) ─────────────────────
String email = securiteHelper.resolveEmail();
if (email == null) {
LOG.warnf("activerAdminOrganisation: impossible de résoudre l'email JWT pour org=%s", organisationId);
return;
}
Membre caller = membreRepository.findByEmail(email).orElse(null);
if (caller == null) {
LOG.warnf("activerAdminOrganisation: aucun membre trouvé pour email=%s", email);
return;
}
Organisation org = organisationRepo.findByIdOptional(organisationId).orElse(null);
if (org == null) {
LOG.warnf("activerAdminOrganisation: organisation introuvable org=%s", organisationId);
return;
}
LOG.infof("activerAdminOrganisation: caller=%s (%s) pour org=%s (%s)",
caller.getId(), email, organisationId, org.getNom());
// ── 2. S'assurer que le caller a un lien MembreOrganisation ────────────
var lienOpt = membreOrganisationRepository
.findByMembreIdAndOrganisationId(caller.getId(), organisationId);
if (lienOpt.isEmpty()) {
LOG.infof("activerAdminOrganisation: pas de lien existant — création pour membre=%s org=%s", caller.getId(), organisationId);
membreService.lierMembreOrganisationEtIncrementerQuota(caller, organisationId, "ACTIF");
} else {
// Activer le lien existant s'il est en attente
var lien = lienOpt.get();
if (lien.getStatutMembre() != dev.lions.unionflow.server.api.enums.membre.StatutMembre.ACTIF) {
lien.setStatutMembre(dev.lions.unionflow.server.api.enums.membre.StatutMembre.ACTIF);
LOG.infof("activerAdminOrganisation: lien existant activé (statut→ACTIF) pour membre=%s", caller.getId());
}
}
// ── 3. Promouvoir en ORGADMIN (DB + Keycloak) ──────────────────────────
try {
membreService.promouvoirAdminOrganisation(caller.getId());
LOG.infof("activerAdminOrganisation: membre %s promu ORGADMIN en DB", caller.getId());
} catch (Exception e) {
LOG.errorf("activerAdminOrganisation: échec promotion DB pour membre=%s : %s", caller.getId(), e.getMessage());
}
try {
keycloakSyncService.promouvoirAdminOrganisationDansKeycloak(caller.getId());
LOG.infof("activerAdminOrganisation: rôle Keycloak ADMIN_ORGANISATION assigné pour membre=%s", caller.getId());
} catch (Exception e) {
LOG.warnf("activerAdminOrganisation: sync Keycloak ORGADMIN échouée pour membre=%s (non-bloquant): %s",
caller.getId(), e.getMessage());
}
}
private void notifierSuperAdmins(SouscriptionOrganisation souscription) {
// Notification simple via NotificationService — non bloquant
LOG.infof("Notification SuperAdmins: nouvelle souscription à valider — org=%s montant=%s XOF",
souscription.getOrganisation().getNom(),
souscription.getMontantTotal());
// L'envoi réel d'email peut être ajouté ici via notificationService
}
// ── Mapping ───────────────────────────────────────────────────────────────
private SouscriptionStatutResponse toStatutResponse(
SouscriptionOrganisation s, String waveLaunchUrl) {
SouscriptionStatutResponse r = new SouscriptionStatutResponse();
r.setSouscriptionId(s.getId() != null ? s.getId().toString() : null);
r.setStatutValidation(s.getStatutValidation() != null ? s.getStatutValidation().name() : null);
r.setStatutLibelle(s.getStatutValidation() != null ? s.getStatutValidation().getLibelle() : null);
r.setTypeFormule(s.getFormule() != null ? s.getFormule().getCode().name() : null);
r.setPlageMembres(s.getPlage() != null ? s.getPlage().name() : null);
r.setPlageLibelle(s.getPlage() != null ? s.getPlage().getLibelle() : null);
r.setTypePeriode(s.getTypePeriode() != null ? s.getTypePeriode().name() : null);
r.setTypeOrganisation(s.getTypeOrganisationSouscription() != null
? s.getTypeOrganisationSouscription().name() : null);
r.setMontantTotal(s.getMontantTotal());
r.setMontantMensuelBase(s.getFormule() != null ? s.getFormule().getPrixMensuel() : null);
r.setCoefficientApplique(s.getCoefficientApplique());
r.setWaveSessionId(s.getWaveSessionId());
// Utilise le waveLaunchUrl passé en paramètre, sinon l'URL stockée en base (pour récupération PAYMENT_INITIATED)
r.setWaveLaunchUrl(waveLaunchUrl != null ? waveLaunchUrl : s.getWaveCheckoutUrl());
r.setDateDebut(s.getDateDebut());
r.setDateFin(s.getDateFin());
r.setDateValidation(s.getDateValidation());
r.setCommentaireRejet(s.getCommentaireRejet());
r.setStatut(s.getStatut() != null ? s.getStatut().name() : null);
if (s.getOrganisation() != null) {
r.setOrganisationId(s.getOrganisation().getId().toString());
r.setOrganisationNom(s.getOrganisation().getNom());
}
// ── Quota & Option C ──────────────────────────────────────────────────────
r.setQuotaMax(s.getQuotaMax());
r.setQuotaUtilise(s.getQuotaUtilise() != null ? s.getQuotaUtilise() : 0);
r.setQuotaDepasse(s.isQuotaDepasse());
if (s.getQuotaMax() != null && s.getQuotaUtilise() != null) {
r.setQuotaRestant(Math.max(0, s.getQuotaMax() - s.getQuotaUtilise()));
}
if (s.getDateFin() != null) {
r.setJoursAvantExpiration(java.time.LocalDate.now().until(s.getDateFin(), java.time.temporal.ChronoUnit.DAYS));
}
if (s.getFormule() != null) {
FormuleAbonnement f = s.getFormule();
r.setPlanCommercial(f.getPlanCommercial());
r.setApiAccess(Boolean.TRUE.equals(f.getApiAccess()));
r.setFederationAccess(Boolean.TRUE.equals(f.getFederationAccess()));
r.setSupportPrioritaire(Boolean.TRUE.equals(f.getSupportPrioritaire()));
r.setSlaGaranti(f.getSlaGaranti());
r.setMaxAdmins(f.getMaxAdmins());
r.setNiveauReporting(f.getNiveauReporting());
}
return r;
}
private FormuleAbonnementResponse toFormuleResponse(FormuleAbonnement f) {
FormuleAbonnementResponse r = new FormuleAbonnementResponse();
r.setCode(f.getCode().name());
r.setLibelle(f.getLibelle());
r.setDescription(f.getDescription());
r.setPlage(f.getPlage().name());
r.setPlageLibelle(f.getPlage().getLibelle());
r.setMinMembres(f.getPlage().getMin());
r.setMaxMembres(f.getPlage().getMaxAffichage());
r.setPrixMensuel(f.getPrixMensuel());
r.setPrixAnnuel(f.getPrixAnnuel());
r.setOrdreAffichage(f.getOrdreAffichage() != null ? f.getOrdreAffichage() : 0);
// Champs Option C (V19)
r.setPlanCommercial(f.getPlanCommercial());
r.setNiveauReporting(f.getNiveauReporting());
r.setApiAccess(Boolean.TRUE.equals(f.getApiAccess()));
r.setFederationAccess(Boolean.TRUE.equals(f.getFederationAccess()));
r.setSupportPrioritaire(Boolean.TRUE.equals(f.getSupportPrioritaire()));
r.setSlaGaranti(f.getSlaGaranti());
r.setMaxAdmins(f.getMaxAdmins() != null ? f.getMaxAdmins() : -1);
return r;
}
// ── Utilitaires ───────────────────────────────────────────────────────────
private UUID parseUuid(String value, String fieldName) {
try {
return UUID.fromString(value);
} catch (Exception e) {
throw new BadRequestException("Format UUID invalide pour " + fieldName + ": " + value);
}
}
private
*
*
* @param souscriptionId UUID de la souscription à approuver
* @param superAdminId UUID du SuperAdmin qui valide
*/
@Transactional
public void approuver(UUID souscriptionId, UUID superAdminId) {
LOG.infof("Approbation souscription=%s par superAdmin=%s", souscriptionId, superAdminId);
SouscriptionOrganisation souscription = findSouscription(souscriptionId);
if (souscription.getStatutValidation() != StatutValidationSouscription.PAIEMENT_CONFIRME
&& souscription.getStatutValidation() != StatutValidationSouscription.VALIDEE) {
throw new BadRequestException("Impossible d'approuver depuis le statut: "
+ souscription.getStatutValidation());
}
if (souscription.getStatutValidation() == StatutValidationSouscription.VALIDEE) {
LOG.infof("Souscription %s déjà validée automatiquement — skip", souscriptionId);
return;
}
LocalDate dateDebut = LocalDate.now();
LocalDate dateFin = dateDebut.plusMonths(souscription.getTypePeriode().getNombreMois());
souscription.setStatutValidation(StatutValidationSouscription.VALIDEE);
souscription.setStatut(StatutSouscription.ACTIVE);
souscription.setDateValidation(dateDebut);
souscription.setValidatedById(superAdminId);
souscription.setDateDebut(dateDebut);
souscription.setDateFin(dateFin);
souscription.setDateDernierPaiement(dateDebut);
souscription.setDateProchainePaiement(dateFin);
souscriptionRepo.persist(souscription);
// Activer le membre admin de l'organisation
activerAdminOrganisation(souscription.getOrganisation().getId());
LOG.infof("Souscription %s approuvée — compte actif jusqu'au %s", souscriptionId, dateFin);
}
/**
* Rejette une souscription avec un commentaire obligatoire.
*
* @param souscriptionId UUID de la souscription à rejeter
* @param superAdminId UUID du SuperAdmin qui rejette
* @param commentaire motif de refus (obligatoire)
*/
@Transactional
public void rejeter(UUID souscriptionId, UUID superAdminId, String commentaire) {
LOG.infof("Rejet souscription=%s par superAdmin=%s — motif: %s",
souscriptionId, superAdminId, commentaire);
if (commentaire == null || commentaire.isBlank()) {
throw new BadRequestException("Le commentaire de rejet est obligatoire");
}
SouscriptionOrganisation souscription = findSouscription(souscriptionId);
if (souscription.getStatutValidation().isTerminal()) {
throw new BadRequestException("La souscription est déjà dans un état terminal: "
+ souscription.getStatutValidation());
}
souscription.setStatutValidation(StatutValidationSouscription.REJETEE);
souscription.setStatut(StatutSouscription.RESILIEE);
souscription.setDateValidation(LocalDate.now());
souscription.setValidatedById(superAdminId);
souscription.setCommentaireRejet(
commentaire.length() > 500 ? commentaire.substring(0, 500) : commentaire);
souscriptionRepo.persist(souscription);
LOG.infof("Souscription %s rejetée", souscriptionId);
}
// ── Méthodes privées ──────────────────────────────────────────────────────
private SouscriptionOrganisation findSouscription(UUID id) {
return souscriptionRepo.findByIdOptional(id)
.orElseThrow(() -> new NotFoundException("Souscription introuvable: " + id));
}
/**
* Active le premier membre avec le rôle d'admin trouvé pour l'organisation.
* Si aucun lien membre-organisation n'existe, tente de créer le lien pour le
* caller courant (email JWT) avant d'activer.
*/
/**
* Active l'admin de l'organisation après paiement de la souscription.
*
*