Files
unionflow-server-impl-quarkus/src/main/java/dev/lions/unionflow/server/service/SouscriptionService.java
2026-04-24 16:19:25 +00:00

646 lines
31 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.
*
* <p>Cycle de vie d'une souscription :
* <pre>
* 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
* </pre>
*
* @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<String, BigDecimal> 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.
*
* <p>Endpoint public — PermitAll.
*/
public List<FormuleAbonnementResponse> 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.
*
* <p>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).
*
* <p>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<SouscriptionStatutResponse> 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<SouscriptionStatutResponse> 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.
*
* <p>Actions effectuées :
* <ol>
* <li>Passe {@code statutValidation} à VALIDEE</li>
* <li>Passe {@code statut} à ACTIVE</li>
* <li>Calcule et persiste les dates de début/fin</li>
* <li>Appelle {@link MembreService#activerMembre(UUID)} pour l'admin de l'org</li>
* </ol>
*
* @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.
*
* <p>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.
*
* <p>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 extends Enum<T>> T parseEnum(Class<T> 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<String, BigDecimal> buildPrixBase() {
java.util.Map<String, BigDecimal> 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);
}
}