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 PRIX_BASE = buildPrixBase(); @Inject SouscriptionOrganisationRepository souscriptionRepo; @Inject FormuleAbonnementRepository formuleRepo; @Inject WaveCheckoutService waveService; @Inject SecuriteHelper securiteHelper; @Inject MembreRepository membreRepository; @Inject OrganisationRepository organisationRepo; @Inject MembreService membreService; @Inject MembreOrganisationRepository membreOrganisationRepository; @Inject NotificationService notificationService; @Inject MembreKeycloakSyncService keycloakSyncService; // ── Catalogue ───────────────────────────────────────────────────────────── /** * Retourne toutes les formules du catalogue, triées par ordre d'affichage. * *

Endpoint public — PermitAll. */ public List getFormules() { return formuleRepo.findAllActifOrderByOrdre() .stream() .map(this::toFormuleResponse) .collect(Collectors.toList()); } // ── Création de la demande ───────────────────────────────────────────────── /** * Crée une demande de souscription pour une organisation. * *

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 listerToutes(UUID organisationId, int page, int size) { if (organisationId != null) { return souscriptionRepo .find("organisation.id = ?1 order by dateCreation desc", organisationId) .page(page, size) .list() .stream() .map(s -> toStatutResponse(s, null)) .collect(Collectors.toList()); } return souscriptionRepo .findAll() .page(page, size) .list() .stream() .map(s -> toStatutResponse(s, null)) .collect(Collectors.toList()); } /** * Retourne la souscription active d'une organisation (SuperAdmin). */ public SouscriptionStatutResponse obtenirActiveParOrganisation(UUID organisationId) { return souscriptionRepo .find("organisation.id = ?1 and statut = ?2 order by dateCreation desc", organisationId, dev.lions.unionflow.server.api.enums.abonnement.StatutSouscription.ACTIVE) .firstResultOptional() .map(s -> toStatutResponse(s, null)) .orElse(null); } /** * Liste les souscriptions en attente de validation SuperAdmin. */ public List getSouscriptionsEnAttenteValidation() { return souscriptionRepo .find("statutValidation = ?1 order by dateCreation asc", StatutValidationSouscription.PAIEMENT_CONFIRME) .list() .stream() .map(s -> toStatutResponse(s, null)) .collect(Collectors.toList()); } /** * Approuve une souscription et active le compte de l'administrateur d'organisation. * *

Actions effectuées : *

    *
  1. Passe {@code statutValidation} à VALIDEE
  2. *
  3. Passe {@code statut} à ACTIVE
  4. *
  5. Calcule et persiste les dates de début/fin
  6. *
  7. Appelle {@link MembreService#activerMembre(UUID)} pour l'admin de l'org
  8. *
* * @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. * *

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 > T parseEnum(Class enumClass, String value, String fieldName) { try { return Enum.valueOf(enumClass, value.toUpperCase()); } catch (Exception e) { throw new BadRequestException("Valeur invalide pour " + fieldName + ": " + value + " — valeurs acceptées: " + java.util.Arrays.toString(enumClass.getEnumConstants())); } } // ── Matrice tarifaire de référence ──────────────────────────────────────── /** * Construit la map de prix de base XOF/mois (TypeFormule × PlageMembres). * Ces valeurs sont une référence — les prix réels sont stockés en base via * la table formules_abonnement et le seed V11. */ private static java.util.Map buildPrixBase() { java.util.Map m = new java.util.HashMap<>(); // PETITE (1-100) m.put("BASIC_PETITE", new BigDecimal("3000")); m.put("STANDARD_PETITE", new BigDecimal("6000")); m.put("PREMIUM_PETITE", new BigDecimal("10000")); // MOYENNE (101-500) m.put("BASIC_MOYENNE", new BigDecimal("8000")); m.put("STANDARD_MOYENNE", new BigDecimal("15000")); m.put("PREMIUM_MOYENNE", new BigDecimal("25000")); // GRANDE (501-2000) m.put("BASIC_GRANDE", new BigDecimal("20000")); m.put("STANDARD_GRANDE", new BigDecimal("35000")); m.put("PREMIUM_GRANDE", new BigDecimal("60000")); // TRES_GRANDE (2000+) m.put("BASIC_TRES_GRANDE", new BigDecimal("50000")); m.put("STANDARD_TRES_GRANDE", new BigDecimal("80000")); m.put("PREMIUM_TRES_GRANDE", new BigDecimal("120000")); return java.util.Collections.unmodifiableMap(m); } }