From 5d028a10bf018ef3965fcaf42b47c158791d528a Mon Sep 17 00:00:00 2001 From: dahoud <41957584+DahoudG@users.noreply.github.com> Date: Wed, 15 Apr 2026 20:23:17 +0000 Subject: [PATCH] =?UTF-8?q?feat(versement):=20nouveau=20module=20Versement?= =?UTF-8?q?=20(paiements=20rattach=C3=A9s=20=C3=A0=20des=20objets)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Entités : Versement, VersementObjet (lien polymorphique vers cotisation/adhesion/etc.) - VersementRepository : requêtes par membre, org, période - VersementResource : endpoints REST /api/versements - VersementService : logique métier (validation, rattachement objets) - Migration V27 : ajout numeroTelephone sur versements --- .../unionflow/server/entity/Versement.java | 171 ++++++ .../server/entity/VersementObjet.java | 82 +++ .../repository/VersementRepository.java | 79 +++ .../server/resource/VersementResource.java | 170 ++++++ .../server/service/VersementService.java | 558 ++++++++++++++++++ ...V27__Add_NumeroTelephone_To_Versements.sql | 10 + 6 files changed, 1070 insertions(+) create mode 100644 src/main/java/dev/lions/unionflow/server/entity/Versement.java create mode 100644 src/main/java/dev/lions/unionflow/server/entity/VersementObjet.java create mode 100644 src/main/java/dev/lions/unionflow/server/repository/VersementRepository.java create mode 100644 src/main/java/dev/lions/unionflow/server/resource/VersementResource.java create mode 100644 src/main/java/dev/lions/unionflow/server/service/VersementService.java create mode 100644 src/main/resources/db/migration/V27__Add_NumeroTelephone_To_Versements.sql 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 versementsObjets = new ArrayList<>(); + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "transaction_wave_id") + private TransactionWave transactionWave; + + // ── Méthodes métier ─────────────────────────────────────────────────────── + + /** Génère une référence unique : VRS-YYYY-XXXXXXXXXXXX */ + public static String genererNumeroReference() { + return "VRS-" + + LocalDateTime.now().getYear() + + "-" + + String.format("%012d", REFERENCE_COUNTER.incrementAndGet() % 1_000_000_000_000L); + } + + /** Vrai si le versement est confirmé (Wave) ou validé (manuel) */ + public boolean isConfirme() { + return "CONFIRME".equals(statutPaiement) || "VALIDE".equals(statutPaiement); + } + + /** Vrai si le versement peut encore être modifié ou annulé */ + public boolean peutEtreModifie() { + return !"CONFIRME".equals(statutPaiement) + && !"VALIDE".equals(statutPaiement) + && !"ANNULE".equals(statutPaiement); + } + + @PrePersist + protected void onCreate() { + super.onCreate(); + if (numeroReference == null || numeroReference.isBlank()) { + numeroReference = genererNumeroReference(); + } + if (statutPaiement == null) { + statutPaiement = "EN_ATTENTE"; + } + if (datePaiement == null) { + datePaiement = LocalDateTime.now(); + } + } +} diff --git a/src/main/java/dev/lions/unionflow/server/entity/VersementObjet.java b/src/main/java/dev/lions/unionflow/server/entity/VersementObjet.java new file mode 100644 index 0000000..8028032 --- /dev/null +++ b/src/main/java/dev/lions/unionflow/server/entity/VersementObjet.java @@ -0,0 +1,82 @@ +package dev.lions.unionflow.server.entity; + +import jakarta.persistence.*; +import jakarta.validation.constraints.*; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.UUID; +import lombok.*; + +/** + * Liaison polymorphique entre un versement et son objet cible. + * + *

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 { + + /** Trouve un versement actif par UUID. */ + public Optional findVersementById(UUID id) { + return find("id = ?1 AND actif = true", id).firstResultOptional(); + } + + /** Trouve un versement par son numéro de référence. */ + public Optional findByNumeroReference(String numeroReference) { + return find("numeroReference", numeroReference).firstResultOptional(); + } + + /** Liste tous les versements actifs d'un membre, les plus récents d'abord. */ + public List findByMembreId(UUID membreId) { + return find( + "membre.id = ?1 AND actif = true", + Sort.by("datePaiement", Sort.Direction.Descending), + membreId + ).list(); + } + + /** Liste les versements par statut. */ + public List findByStatut(StatutPaiement statut) { + return find( + "statutPaiement = ?1 AND actif = true", + Sort.by("datePaiement", Sort.Direction.Descending), + statut.name() + ).list(); + } + + /** Liste les versements par méthode. */ + public List findByMethode(MethodePaiement methode) { + return find( + "methodePaiement = ?1 AND actif = true", + Sort.by("datePaiement", Sort.Direction.Descending), + methode.name() + ).list(); + } + + /** Liste les versements confirmés dans une période donnée. */ + public List findConfirmesParPeriode(LocalDateTime dateDebut, LocalDateTime dateFin) { + return find( + "statutPaiement IN ('CONFIRME', 'VALIDE') " + + "AND dateValidation >= ?1 AND dateValidation <= ?2 AND actif = true", + Sort.by("dateValidation", Sort.Direction.Descending), + dateDebut, + dateFin + ).list(); + } + + /** Calcule le montant total des versements confirmés dans une période. */ + public BigDecimal calculerMontantTotalConfirmes(LocalDateTime dateDebut, LocalDateTime dateFin) { + return findConfirmesParPeriode(dateDebut, dateFin).stream() + .map(Versement::getMontant) + .reduce(BigDecimal.ZERO, BigDecimal::add); + } +} diff --git a/src/main/java/dev/lions/unionflow/server/resource/VersementResource.java b/src/main/java/dev/lions/unionflow/server/resource/VersementResource.java new file mode 100644 index 0000000..cfc6a32 --- /dev/null +++ b/src/main/java/dev/lions/unionflow/server/resource/VersementResource.java @@ -0,0 +1,170 @@ +package dev.lions.unionflow.server.resource; + +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.service.VersementService; +import jakarta.annotation.security.RolesAllowed; +import jakarta.inject.Inject; +import jakarta.validation.Valid; +import jakarta.ws.rs.*; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import java.util.List; +import java.util.UUID; +import org.eclipse.microprofile.openapi.annotations.tags.Tag; +import org.jboss.logging.Logger; + +/** + * Resource REST pour la gestion des versements. + * + *

Endpoints principaux : + *

+ * + * @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 result = versementService.listerParMembre(membreId); + return Response.ok(result).build(); + } + + @GET + @Path("/mes-versements") + @RolesAllowed({"MEMBRE", "ADMIN", "ADMIN_ORGANISATION"}) + public Response getMesVersements( + @QueryParam("limit") @DefaultValue("20") int limit) { + LOG.infof("GET /api/versements/mes-versements?limit=%d", limit); + List result = versementService.getMesVersements(limit); + return Response.ok(result).build(); + } + + // ── Validation / Annulation ─────────────────────────────────────────────── + + @POST + @Path("/{id}/valider") + @RolesAllowed({"ADMIN", "ADMIN_ORGANISATION"}) + public Response validerVersement(@PathParam("id") UUID id) { + LOG.infof("POST /api/versements/%s/valider", id); + VersementResponse result = versementService.validerVersement(id); + return Response.ok(result).build(); + } + + @POST + @Path("/{id}/annuler") + @RolesAllowed({"ADMIN", "ADMIN_ORGANISATION", "MEMBRE"}) + public Response annulerVersement(@PathParam("id") UUID id) { + LOG.infof("POST /api/versements/%s/annuler", id); + VersementResponse result = versementService.annulerVersement(id); + return Response.ok(result).build(); + } + + // ── Flux Wave ───────────────────────────────────────────────────────────── + + /** + * Initie un versement Wave. + * + *

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 : + *

    + *
  1. Wave : deep link natif, app Wave sur le même téléphone, retour via + * {@code unionflow://payment?result=success&ref={intentionId}}
  2. + *
  3. Manuel : déclaration espèces/virement/chèque → validation trésorier
  4. + *
+ * + * @author UnionFlow Team + * @version 4.0 + * @since 2026-04-13 + */ +@ApplicationScoped +public class VersementService { + + private static final Logger LOG = Logger.getLogger(VersementService.class); + + @Inject VersementRepository versementRepository; + @Inject MembreRepository membreRepository; + @Inject KeycloakService keycloakService; + @Inject TypeReferenceRepository typeReferenceRepository; + @Inject IntentionPaiementRepository intentionPaiementRepository; + @Inject WaveCheckoutService waveCheckoutService; + @Inject CompteEpargneRepository compteEpargneRepository; + @Inject MembreOrganisationRepository membreOrganisationRepository; + @Inject NotificationService notificationService; + @Inject io.quarkus.security.identity.SecurityIdentity securityIdentity; + + // ── Lecture ─────────────────────────────────────────────────────────────── + + public VersementResponse trouverParId(UUID id) { + return versementRepository.findVersementById(id) + .map(this::convertToResponse) + .orElseThrow(() -> new NotFoundException("Versement non trouvé : " + id)); + } + + public VersementResponse trouverParNumeroReference(String numeroReference) { + return versementRepository.findByNumeroReference(numeroReference) + .map(this::convertToResponse) + .orElseThrow(() -> new NotFoundException("Versement non trouvé : " + numeroReference)); + } + + public List listerParMembre(UUID membreId) { + return versementRepository.findByMembreId(membreId).stream() + .map(this::convertToSummaryResponse) + .collect(Collectors.toList()); + } + + public BigDecimal calculerMontantTotalConfirmes(LocalDateTime dateDebut, LocalDateTime dateFin) { + return versementRepository.calculerMontantTotalConfirmes(dateDebut, dateFin); + } + + /** + * Historique des versements du membre connecté (auto-détection). + * Retourne uniquement les versements confirmés/validés. + */ + public List getMesVersements(int limit) { + Membre membreConnecte = getMembreConnecte(); + LOG.infof("Historique versements pour %s, limit=%d", + membreConnecte.getNumeroMembre(), limit); + + List versements = versementRepository.getEntityManager() + .createQuery( + "SELECT v FROM Versement v " + + "WHERE v.membre.id = :membreId " + + "AND v.statutPaiement IN ('CONFIRME', 'VALIDE') " + + "ORDER BY v.datePaiement DESC", + Versement.class) + .setParameter("membreId", membreConnecte.getId()) + .setMaxResults(limit) + .getResultList(); + + return versements.stream() + .map(this::convertToSummaryResponse) + .collect(Collectors.toList()); + } + + // ── Validation / Annulation ─────────────────────────────────────────────── + + @Transactional + public VersementResponse validerVersement(UUID id) { + Versement versement = versementRepository.findVersementById(id) + .orElseThrow(() -> new NotFoundException("Versement non trouvé : " + id)); + + if (versement.isConfirme()) { + return convertToResponse(versement); + } + + versement.setStatutPaiement("CONFIRME"); + versement.setDateValidation(LocalDateTime.now()); + versement.setValidateur(keycloakService.getCurrentUserEmail()); + versement.setModifiePar(keycloakService.getCurrentUserEmail()); + versementRepository.persist(versement); + + LOG.infof("Versement validé : %s", id); + return convertToResponse(versement); + } + + @Transactional + public VersementResponse annulerVersement(UUID id) { + Versement versement = versementRepository.findVersementById(id) + .orElseThrow(() -> new NotFoundException("Versement non trouvé : " + id)); + + if (!versement.peutEtreModifie()) { + throw new IllegalStateException("Le versement ne peut plus être annulé (statut finalisé)"); + } + + versement.setStatutPaiement("ANNULE"); + versement.setModifiePar(keycloakService.getCurrentUserEmail()); + versementRepository.persist(versement); + + LOG.infof("Versement annulé : %s", id); + return convertToResponse(versement); + } + + // ── Flux Wave ───────────────────────────────────────────────────────────── + + /** + * Initie un versement Wave. + * + *
    + *
  1. Crée une {@link IntentionPaiement} (hub Wave interne)
  2. + *
  3. Appelle l'API Wave Checkout → obtient {@code waveLaunchUrl}
  4. + *
  5. Retourne {@link VersementGatewayResponse} avec {@code waveLaunchUrl} + * pour que {@code url_launcher} ouvre Wave directement
  6. + *
+ */ + @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);