diff --git a/src/main/java/dev/lions/unionflow/server/entity/Versement.java b/src/main/java/dev/lions/unionflow/server/entity/Versement.java new file mode 100644 index 0000000..21d3aa0 --- /dev/null +++ b/src/main/java/dev/lions/unionflow/server/entity/Versement.java @@ -0,0 +1,171 @@ +package dev.lions.unionflow.server.entity; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import jakarta.persistence.*; +import jakarta.validation.constraints.*; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicLong; +import lombok.*; + +/** + * Versement — acte de régler une cotisation ou de déposer des fonds. + * + *
Un versement peut être effectué : + *
Table DB : {@code paiements} (nom hérité, conservé pour compatibilité Flyway).
+ *
+ * @author UnionFlow Team
+ * @version 4.0
+ * @since 2026-04-13
+ */
+@Entity
+@Table(name = "paiements", indexes = {
+ @Index(name = "idx_paiement_numero_reference", columnList = "numero_reference", unique = true),
+ @Index(name = "idx_paiement_membre", columnList = "membre_id"),
+ @Index(name = "idx_paiement_statut", columnList = "statut_paiement"),
+ @Index(name = "idx_paiement_methode", columnList = "methode_paiement"),
+ @Index(name = "idx_paiement_date", columnList = "date_paiement")
+})
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+@EqualsAndHashCode(callSuper = true)
+public class Versement extends BaseEntity {
+
+ private static final AtomicLong REFERENCE_COUNTER =
+ new AtomicLong(System.currentTimeMillis() % 1_000_000_000_000L);
+
+ // ── Identité ──────────────────────────────────────────────────────────────
+
+ /** Référence unique (ex: VRS-2026-XXXXXXXXXXXX) */
+ @NotBlank
+ @Column(name = "numero_reference", unique = true, nullable = false, length = 50)
+ private String numeroReference;
+
+ // ── Montant ───────────────────────────────────────────────────────────────
+
+ @NotNull
+ @DecimalMin(value = "0.0", message = "Le montant doit être positif")
+ @Digits(integer = 12, fraction = 2)
+ @Column(name = "montant", nullable = false, precision = 14, scale = 2)
+ private BigDecimal montant;
+
+ @NotBlank
+ @Pattern(regexp = "^[A-Z]{3}$", message = "Code devise ISO à 3 lettres requis")
+ @Column(name = "code_devise", nullable = false, length = 3)
+ private String codeDevise;
+
+ // ── Méthode & Statut ──────────────────────────────────────────────────────
+
+ /** WAVE | ESPECES | VIREMENT | CHEQUE | AUTRE */
+ @NotNull
+ @Column(name = "methode_paiement", nullable = false, length = 50)
+ private String methodePaiement;
+
+ /** EN_ATTENTE | EN_COURS | CONFIRME | ECHEC | EN_ATTENTE_VALIDATION | ANNULE */
+ @NotNull
+ @Builder.Default
+ @Column(name = "statut_paiement", nullable = false, length = 30)
+ private String statutPaiement = "EN_ATTENTE";
+
+ // ── Dates ─────────────────────────────────────────────────────────────────
+
+ @Column(name = "date_paiement")
+ private LocalDateTime datePaiement;
+
+ @Column(name = "date_validation")
+ private LocalDateTime dateValidation;
+
+ // ── Validation ────────────────────────────────────────────────────────────
+
+ @Column(name = "validateur", length = 255)
+ private String validateur;
+
+ // ── Traçabilité ───────────────────────────────────────────────────────────
+
+ /** ID transaction Wave (TCN...) ou référence chèque / bordereau */
+ @Column(name = "reference_externe", length = 500)
+ private String referenceExterne;
+
+ @Column(name = "url_preuve", length = 1000)
+ private String urlPreuve;
+
+ @Column(name = "commentaire", length = 1000)
+ private String commentaire;
+
+ @Column(name = "ip_address", length = 45)
+ private String ipAddress;
+
+ @Column(name = "user_agent", length = 500)
+ private String userAgent;
+
+ // ── Téléphone Wave ────────────────────────────────────────────────────────
+
+ /**
+ * Numéro de téléphone Wave utilisé pour ce versement.
+ * Pré-rempli depuis le profil du membre (même téléphone qu'UnionFlow),
+ * modifiable à l'étape "Récapitulatif" avant de tapper "Payer".
+ */
+ @Column(name = "numero_telephone", length = 20)
+ private String numeroTelephone;
+
+ // ── Relations ─────────────────────────────────────────────────────────────
+
+ @NotNull
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "membre_id", nullable = false)
+ private Membre membre;
+
+ @JsonIgnore
+ @OneToMany(mappedBy = "versement", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
+ @Builder.Default
+ private List Remplace les 4 tables dupliquées (paiements_cotisations, paiements_adhesions,
+ * paiements_evenements, paiements_aides) par une table unique utilisant le pattern
+ * {@code (typeObjetCible, objetCibleId)}.
+ *
+ * Table DB : {@code paiements_objets} (nom hérité, conservé pour compatibilité Flyway).
+ *
+ * @author UnionFlow Team
+ * @version 4.0
+ * @since 2026-04-13
+ */
+@Entity
+@Table(name = "paiements_objets", indexes = {
+ @Index(name = "idx_po_paiement", columnList = "paiement_id"),
+ @Index(name = "idx_po_objet", columnList = "type_objet_cible, objet_cible_id"),
+ @Index(name = "idx_po_type", columnList = "type_objet_cible")
+}, uniqueConstraints = {
+ @UniqueConstraint(name = "uk_paiement_objet", columnNames = {
+ "paiement_id", "type_objet_cible", "objet_cible_id"
+ })
+})
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+@EqualsAndHashCode(callSuper = true)
+public class VersementObjet extends BaseEntity {
+
+ /** Versement parent. */
+ @NotNull
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "paiement_id", nullable = false)
+ private Versement versement;
+
+ /**
+ * Type de l'objet cible (domaine {@code OBJET_PAIEMENT}).
+ * Valeurs : COTISATION | ADHESION | EVENEMENT | AIDE.
+ */
+ @NotBlank
+ @Size(max = 50)
+ @Column(name = "type_objet_cible", nullable = false, length = 50)
+ private String typeObjetCible;
+
+ /** UUID de l'objet cible (cotisation, adhésion, inscription, aide). */
+ @NotNull
+ @Column(name = "objet_cible_id", nullable = false)
+ private UUID objetCibleId;
+
+ /** Montant affecté à cet objet cible. */
+ @NotNull
+ @DecimalMin(value = "0.0", message = "Le montant doit être positif")
+ @Digits(integer = 12, fraction = 2)
+ @Column(name = "montant_applique", nullable = false, precision = 14, scale = 2)
+ private BigDecimal montantApplique;
+
+ @Column(name = "date_application")
+ private LocalDateTime dateApplication;
+
+ @Size(max = 500)
+ @Column(name = "commentaire", length = 500)
+ private String commentaire;
+
+ @Override
+ @PrePersist
+ protected void onCreate() {
+ super.onCreate();
+ if (dateApplication == null) {
+ dateApplication = LocalDateTime.now();
+ }
+ }
+}
diff --git a/src/main/java/dev/lions/unionflow/server/repository/VersementRepository.java b/src/main/java/dev/lions/unionflow/server/repository/VersementRepository.java
new file mode 100644
index 0000000..4a03800
--- /dev/null
+++ b/src/main/java/dev/lions/unionflow/server/repository/VersementRepository.java
@@ -0,0 +1,79 @@
+package dev.lions.unionflow.server.repository;
+
+import dev.lions.unionflow.server.api.enums.paiement.MethodePaiement;
+import dev.lions.unionflow.server.api.enums.paiement.StatutPaiement;
+import dev.lions.unionflow.server.entity.Versement;
+import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase;
+import io.quarkus.panache.common.Sort;
+import jakarta.enterprise.context.ApplicationScoped;
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Optional;
+import java.util.UUID;
+
+/**
+ * Repository pour l'entité {@link Versement}.
+ *
+ * @author UnionFlow Team
+ * @version 4.0
+ * @since 2026-04-13
+ */
+@ApplicationScoped
+public class VersementRepository implements PanacheRepositoryBase Endpoints principaux :
+ * Le mobile appelle cet endpoint puis ouvre le {@code waveLaunchUrl} retourné
+ * avec {@code url_launcher}. Wave s'ouvre avec le montant et le numéro pré-remplis.
+ * Après confirmation, Wave redirige vers
+ * {@code unionflow://payment?result=success&ref={clientReference}}.
+ */
+ @POST
+ @Path("/initier-wave")
+ @RolesAllowed({"MEMBRE", "MEMBRE_ACTIF", "ADMIN", "ADMIN_ORGANISATION", "USER"})
+ public Response initierVersementWave(@Valid InitierVersementWaveRequest request) {
+ LOG.infof("POST /api/versements/initier-wave — cotisation: %s", request.cotisationId());
+ VersementGatewayResponse result = versementService.initierVersementWave(request);
+ return Response.status(Response.Status.CREATED).entity(result).build();
+ }
+
+ /**
+ * Retour deep link / polling du statut d'un versement Wave.
+ *
+ * Appelé par le mobile au retour du deep link
+ * {@code unionflow://payment?result=success&ref={intentionId}}
+ * pour confirmer que le paiement est bien enregistré côté UnionFlow.
+ * Également utilisé par le web en polling toutes les 3 secondes.
+ */
+ @GET
+ @Path("/statut/{intentionId}")
+ @RolesAllowed({"MEMBRE", "MEMBRE_ACTIF", "ADMIN", "ADMIN_ORGANISATION", "USER"})
+ public Response getStatutVersement(@PathParam("intentionId") UUID intentionId) {
+ LOG.infof("GET /api/versements/statut/%s", intentionId);
+ VersementStatutResponse result = versementService.verifierStatutVersement(intentionId);
+ return Response.ok(result).build();
+ }
+
+ // ── Flux manuel ───────────────────────────────────────────────────────────
+
+ /**
+ * Déclare un versement manuel (espèces, virement, chèque).
+ * Le versement est créé avec le statut EN_ATTENTE_VALIDATION.
+ * Le trésorier devra le valider via la page admin.
+ */
+ @POST
+ @Path("/declarer-manuel")
+ @RolesAllowed({"MEMBRE", "ADMIN", "ADMIN_ORGANISATION"})
+ public Response declarerVersementManuel(@Valid DeclarerVersementManuelRequest request) {
+ LOG.infof("POST /api/versements/declarer-manuel — cotisation: %s, méthode: %s",
+ request.cotisationId(), request.methodePaiement());
+ VersementResponse result = versementService.declarerVersementManuel(request);
+ return Response.status(Response.Status.CREATED).entity(result).build();
+ }
+
+ // ── Dépôt épargne ─────────────────────────────────────────────────────────
+
+ @POST
+ @Path("/initier-depot-epargne")
+ @RolesAllowed({"MEMBRE", "MEMBRE_ACTIF", "ADMIN", "ADMIN_ORGANISATION", "USER"})
+ public Response initierDepotEpargne(@Valid InitierDepotEpargneRequest request) {
+ LOG.infof("POST /api/versements/initier-depot-epargne — compte: %s, montant: %s",
+ request.compteId(), request.montant());
+ VersementGatewayResponse result = versementService.initierDepotEpargneEnLigne(request);
+ return Response.status(Response.Status.CREATED).entity(result).build();
+ }
+}
diff --git a/src/main/java/dev/lions/unionflow/server/service/VersementService.java b/src/main/java/dev/lions/unionflow/server/service/VersementService.java
new file mode 100644
index 0000000..2013a7a
--- /dev/null
+++ b/src/main/java/dev/lions/unionflow/server/service/VersementService.java
@@ -0,0 +1,558 @@
+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 :
+ *
+ *
+ *
+ * @author UnionFlow Team
+ * @version 4.0
+ * @since 2026-04-13
+ */
+@Path("/api/versements")
+@Produces(MediaType.APPLICATION_JSON)
+@Consumes(MediaType.APPLICATION_JSON)
+@RolesAllowed({"ADMIN", "ADMIN_ORGANISATION", "MEMBRE", "USER"})
+@Tag(name = "Versements", description = "Versements de cotisations — Wave et manuel")
+public class VersementResource {
+
+ private static final Logger LOG = Logger.getLogger(VersementResource.class);
+
+ @Inject
+ VersementService versementService;
+
+ // ── Lecture ───────────────────────────────────────────────────────────────
+
+ @GET
+ @Path("/{id}")
+ public Response trouverParId(@PathParam("id") UUID id) {
+ LOG.infof("GET /api/versements/%s", id);
+ VersementResponse result = versementService.trouverParId(id);
+ return Response.ok(result).build();
+ }
+
+ @GET
+ @Path("/reference/{numeroReference}")
+ public Response trouverParNumeroReference(
+ @PathParam("numeroReference") String numeroReference) {
+ LOG.infof("GET /api/versements/reference/%s", numeroReference);
+ VersementResponse result = versementService.trouverParNumeroReference(numeroReference);
+ return Response.ok(result).build();
+ }
+
+ @GET
+ @Path("/membre/{membreId}")
+ public Response listerParMembre(@PathParam("membreId") UUID membreId) {
+ LOG.infof("GET /api/versements/membre/%s", membreId);
+ List
+ *
+ *
+ * @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
+ *
+ */
+ @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;
+ }
+}
diff --git a/src/main/resources/db/migration/V27__Add_NumeroTelephone_To_Versements.sql b/src/main/resources/db/migration/V27__Add_NumeroTelephone_To_Versements.sql
new file mode 100644
index 0000000..dc9cf82
--- /dev/null
+++ b/src/main/resources/db/migration/V27__Add_NumeroTelephone_To_Versements.sql
@@ -0,0 +1,10 @@
+-- V27 : Ajoute la colonne numero_telephone dans la table paiements (versements)
+--
+-- Contexte : La refonte conceptuelle remplace "Paiement" par "Versement".
+-- L'application Wave est installée sur le même téléphone qu'UnionFlow ;
+-- le numéro de téléphone du membre est envoyé automatiquement depuis son profil
+-- afin de pré-remplir le formulaire Wave (deep link natif).
+-- Ce champ mémorise le numéro utilisé lors du versement Wave.
+
+ALTER TABLE paiements
+ ADD COLUMN IF NOT EXISTS numero_telephone VARCHAR(20);