fix(paiement): rendre colonnes legacy nullables + refactor Paiement/PaiementObjet

Migrations :
- V25 : numero_transaction nullable dans paiements (legacy V1 NOT NULL bloquant INSERT)
- V26 : autres colonnes legacy NOT NULL V1 (type_paiement, statut_paiement, etc.)
  rendues nullables pour alignement avec l'entité Paiement

Refactor Paiement/PaiementObjet : mise à jour entités, repository, resource, service
pour cohérence avec le nouveau module Versement. Tests associés supprimés/ajustés.
This commit is contained in:
dahoud
2026-04-15 20:23:30 +00:00
parent 5d028a10bf
commit 217021933e
12 changed files with 582 additions and 2317 deletions

View File

@@ -5,7 +5,6 @@ import jakarta.persistence.*;
import jakarta.validation.constraints.*; import jakarta.validation.constraints.*;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.concurrent.atomic.AtomicLong;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
@@ -15,8 +14,8 @@ import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
/** /**
* Entité Paiement centralisée pour tous les types de paiements * Entité Paiement centralisée pour tous les types de paiements.
* Réutilisable pour cotisations, adhésions, événements, aides * Réutilisable pour cotisations, adhésions, événements, aides.
* *
* @author UnionFlow Team * @author UnionFlow Team
* @version 3.0 * @version 3.0
@@ -104,7 +103,7 @@ public class Paiement extends BaseEntity {
@JoinColumn(name = "membre_id", nullable = false) @JoinColumn(name = "membre_id", nullable = false)
private Membre membre; private Membre membre;
/** Objets cibles de ce paiement (Cat.2 — polymorphique) */ /** Objets cibles de ce paiement (polymorphique) */
@JsonIgnore @JsonIgnore
@OneToMany(mappedBy = "paiement", cascade = CascadeType.ALL, fetch = FetchType.LAZY) @OneToMany(mappedBy = "paiement", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
@Builder.Default @Builder.Default
@@ -115,18 +114,15 @@ public class Paiement extends BaseEntity {
@JoinColumn(name = "transaction_wave_id") @JoinColumn(name = "transaction_wave_id")
private TransactionWave transactionWave; private TransactionWave transactionWave;
private static final AtomicLong REFERENCE_COUNTER = /** Génère un numéro de référence unique */
new AtomicLong(System.currentTimeMillis() % 1000000000000L);
/** Méthode métier pour générer un numéro de référence unique */
public static String genererNumeroReference() { public static String genererNumeroReference() {
return "PAY-" return "PAY-"
+ LocalDateTime.now().getYear() + LocalDateTime.now().getYear()
+ "-" + "-"
+ String.format("%012d", REFERENCE_COUNTER.incrementAndGet() % 1000000000000L); + String.format("%012d", System.currentTimeMillis() % 1000000000000L);
} }
/** Méthode métier pour vérifier si le paiement est validé */ /** Vérifie si le paiement est validé */
public boolean isValide() { public boolean isValide() {
return "VALIDE".equals(statutPaiement); return "VALIDE".equals(statutPaiement);
} }
@@ -137,12 +133,10 @@ public class Paiement extends BaseEntity {
&& !"ANNULE".equals(statutPaiement); && !"ANNULE".equals(statutPaiement);
} }
/** Callback JPA avant la persistance */
@PrePersist @PrePersist
protected void onCreate() { protected void onCreate() {
super.onCreate(); super.onCreate();
if (numeroReference == null if (numeroReference == null || numeroReference.isEmpty()) {
|| numeroReference.isEmpty()) {
numeroReference = genererNumeroReference(); numeroReference = genererNumeroReference();
} }
if (statutPaiement == null) { if (statutPaiement == null) {

View File

@@ -1,19 +1,7 @@
package dev.lions.unionflow.server.entity; package dev.lions.unionflow.server.entity;
import jakarta.persistence.Column; import jakarta.persistence.*;
import jakarta.persistence.Entity; import jakarta.validation.constraints.*;
import jakarta.persistence.FetchType;
import jakarta.persistence.Index;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.PrePersist;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.Digits;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.UUID; import java.util.UUID;
@@ -24,23 +12,11 @@ import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
/** /**
* Table de liaison polymorphique entre un paiement * Table de liaison polymorphique entre un paiement et son objet cible.
* et son objet cible.
* *
* <p> * <p>Remplace les tables dupliquées {@code paiements_cotisations},
* Remplace les 4 tables dupliquées * {@code paiements_adhesions}, etc. par une table unique utilisant
* {@code paiements_cotisations}, * le pattern {@code (type_objet_cible, objet_cible_id)}.
* {@code paiements_adhesions},
* {@code paiements_evenements} et
* {@code paiements_aides} par une table unique
* utilisant le pattern
* {@code (type_objet_cible, objet_cible_id)}.
*
* <p>
* Les types d'objet cible sont définis dans le
* domaine {@code OBJET_PAIEMENT} de la table
* {@code types_reference} (ex: COTISATION,
* ADHESION, EVENEMENT, AIDE).
* *
* @author UnionFlow Team * @author UnionFlow Team
* @version 3.0 * @version 3.0
@@ -49,15 +25,11 @@ import lombok.NoArgsConstructor;
@Entity @Entity
@Table(name = "paiements_objets", indexes = { @Table(name = "paiements_objets", indexes = {
@Index(name = "idx_po_paiement", columnList = "paiement_id"), @Index(name = "idx_po_paiement", columnList = "paiement_id"),
@Index(name = "idx_po_objet", columnList = "type_objet_cible," @Index(name = "idx_po_objet", columnList = "type_objet_cible, objet_cible_id"),
+ " objet_cible_id"),
@Index(name = "idx_po_type", columnList = "type_objet_cible") @Index(name = "idx_po_type", columnList = "type_objet_cible")
}, uniqueConstraints = { }, uniqueConstraints = {
@UniqueConstraint(name = "uk_paiement_objet", columnNames = { @UniqueConstraint(name = "uk_paiement_objet",
"paiement_id", columnNames = {"paiement_id", "type_objet_cible", "objet_cible_id"})
"type_objet_cible",
"objet_cible_id"
})
}) })
@Data @Data
@NoArgsConstructor @NoArgsConstructor
@@ -73,25 +45,14 @@ public class PaiementObjet extends BaseEntity {
private Paiement paiement; private Paiement paiement;
/** /**
* Type de l'objet cible (code du domaine * Type de l'objet cible (ex: COTISATION, ADHESION, EVENEMENT, AIDE).
* {@code OBJET_PAIEMENT} dans
* {@code types_reference}).
*
* <p>
* Valeurs attendues : {@code COTISATION},
* {@code ADHESION}, {@code EVENEMENT},
* {@code AIDE}.
*/ */
@NotBlank @NotBlank
@Size(max = 50) @Size(max = 50)
@Column(name = "type_objet_cible", nullable = false, length = 50) @Column(name = "type_objet_cible", nullable = false, length = 50)
private String typeObjetCible; private String typeObjetCible;
/** /** UUID de l'objet cible. */
* UUID de l'objet cible (cotisation, demande
* d'adhésion, inscription événement, ou demande
* d'aide).
*/
@NotNull @NotNull
@Column(name = "objet_cible_id", nullable = false) @Column(name = "objet_cible_id", nullable = false)
private UUID objetCibleId; private UUID objetCibleId;
@@ -112,13 +73,6 @@ public class PaiementObjet extends BaseEntity {
@Column(name = "commentaire", length = 500) @Column(name = "commentaire", length = 500)
private String commentaire; private String commentaire;
/**
* Callback JPA avant la persistance.
*
* <p>
* Initialise {@code dateApplication} si non
* renseignée.
*/
@Override @Override
@PrePersist @PrePersist
protected void onCreate() { protected void onCreate() {

View File

@@ -1,10 +1,7 @@
package dev.lions.unionflow.server.repository; 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.Paiement; import dev.lions.unionflow.server.entity.Paiement;
import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase; import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase;
import io.quarkus.panache.common.Page;
import io.quarkus.panache.common.Sort; import io.quarkus.panache.common.Sort;
import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.context.ApplicationScoped;
import java.math.BigDecimal; import java.math.BigDecimal;
@@ -14,7 +11,7 @@ import java.util.Optional;
import java.util.UUID; import java.util.UUID;
/** /**
* Repository pour l'entité Paiement * Repository pour l'entité Paiement.
* *
* @author UnionFlow Team * @author UnionFlow Team
* @version 3.0 * @version 3.0
@@ -23,90 +20,57 @@ import java.util.UUID;
@ApplicationScoped @ApplicationScoped
public class PaiementRepository implements PanacheRepositoryBase<Paiement, UUID> { public class PaiementRepository implements PanacheRepositoryBase<Paiement, UUID> {
/** /** Trouve un paiement actif par son UUID. */
* Trouve un paiement par son UUID
*
* @param id UUID du paiement
* @return Paiement ou Optional.empty()
*/
public Optional<Paiement> findPaiementById(UUID id) { public Optional<Paiement> findPaiementById(UUID id) {
return find("id = ?1 AND actif = true", id).firstResultOptional(); return find("id = ?1 AND actif = true", id).firstResultOptional();
} }
/** /** Trouve un paiement par son numéro de référence. */
* Trouve un paiement par son numéro de référence
*
* @param numeroReference Numéro de référence
* @return Paiement ou Optional.empty()
*/
public Optional<Paiement> findByNumeroReference(String numeroReference) { public Optional<Paiement> findByNumeroReference(String numeroReference) {
return find("numeroReference", numeroReference).firstResultOptional(); return find("numeroReference", numeroReference).firstResultOptional();
} }
/** /** Tous les paiements actifs d'un membre, triés par date décroissante. */
* Trouve tous les paiements d'un membre
*
* @param membreId ID du membre
* @return Liste des paiements
*/
public List<Paiement> findByMembreId(UUID membreId) { public List<Paiement> findByMembreId(UUID membreId) {
return find("membre.id = ?1 AND actif = true", Sort.by("datePaiement", Sort.Direction.Descending), membreId) return find(
"membre.id = ?1 AND actif = true",
Sort.by("datePaiement", Sort.Direction.Descending),
membreId)
.list(); .list();
} }
/** /** Paiements actifs par statut (valeur String), triés par date décroissante. */
* Trouve les paiements par statut public List<Paiement> findByStatut(String statut) {
* return find(
* @param statut Statut du paiement "statutPaiement = ?1 AND actif = true",
* @return Liste des paiements Sort.by("datePaiement", Sort.Direction.Descending),
*/ statut)
public List<Paiement> findByStatut(StatutPaiement statut) {
return find("statutPaiement = ?1 AND actif = true", Sort.by("datePaiement", Sort.Direction.Descending), statut.name())
.list(); .list();
} }
/** /** Paiements actifs par méthode (valeur String), triés par date décroissante. */
* Trouve les paiements par méthode public List<Paiement> findByMethode(String methode) {
* return find(
* @param methode Méthode de paiement "methodePaiement = ?1 AND actif = true",
* @return Liste des paiements Sort.by("datePaiement", Sort.Direction.Descending),
*/ methode)
public List<Paiement> findByMethode(MethodePaiement methode) {
return find("methodePaiement = ?1 AND actif = true", Sort.by("datePaiement", Sort.Direction.Descending), methode.name())
.list(); .list();
} }
/** /** Paiements validés dans une période, triés par date de validation décroissante. */
* Trouve les paiements validés dans une période
*
* @param dateDebut Date de début
* @param dateFin Date de fin
* @return Liste des paiements
*/
public List<Paiement> findValidesParPeriode(LocalDateTime dateDebut, LocalDateTime dateFin) { public List<Paiement> findValidesParPeriode(LocalDateTime dateDebut, LocalDateTime dateFin) {
return find( return find(
"statutPaiement = ?1 AND dateValidation >= ?2 AND dateValidation <= ?3 AND actif = true", "statutPaiement = 'VALIDE' AND dateValidation >= ?1 AND dateValidation <= ?2 AND actif = true",
Sort.by("dateValidation", Sort.Direction.Descending), Sort.by("dateValidation", Sort.Direction.Descending),
StatutPaiement.VALIDE.name(),
dateDebut, dateDebut,
dateFin) dateFin)
.list(); .list();
} }
/** /** Somme des montants validés sur une période. */
* Calcule le montant total des paiements validés dans une période
*
* @param dateDebut Date de début
* @param dateFin Date de fin
* @return Montant total
*/
public BigDecimal calculerMontantTotalValides(LocalDateTime dateDebut, LocalDateTime dateFin) { public BigDecimal calculerMontantTotalValides(LocalDateTime dateDebut, LocalDateTime dateFin) {
List<Paiement> paiements = findValidesParPeriode(dateDebut, dateFin); return findValidesParPeriode(dateDebut, dateFin).stream()
return paiements.stream()
.map(Paiement::getMontant) .map(Paiement::getMontant)
.reduce(BigDecimal.ZERO, BigDecimal::add); .reduce(BigDecimal.ZERO, BigDecimal::add);
} }
} }

View File

@@ -1,6 +1,10 @@
package dev.lions.unionflow.server.resource; package dev.lions.unionflow.server.resource;
import dev.lions.unionflow.server.api.dto.paiement.request.CreatePaiementRequest; import dev.lions.unionflow.server.api.dto.paiement.request.CreatePaiementRequest;
import dev.lions.unionflow.server.api.dto.paiement.request.DeclarerPaiementManuelRequest;
import dev.lions.unionflow.server.api.dto.paiement.request.InitierPaiementEnLigneRequest;
import dev.lions.unionflow.server.api.dto.paiement.response.IntentionStatutResponse;
import dev.lions.unionflow.server.api.dto.paiement.response.PaiementGatewayResponse;
import dev.lions.unionflow.server.api.dto.paiement.response.PaiementResponse; import dev.lions.unionflow.server.api.dto.paiement.response.PaiementResponse;
import dev.lions.unionflow.server.api.dto.paiement.response.PaiementSummaryResponse; import dev.lions.unionflow.server.api.dto.paiement.response.PaiementSummaryResponse;
import dev.lions.unionflow.server.service.PaiementService; import dev.lions.unionflow.server.service.PaiementService;
@@ -16,17 +20,25 @@ import org.eclipse.microprofile.openapi.annotations.tags.Tag;
import org.jboss.logging.Logger; import org.jboss.logging.Logger;
/** /**
* Resource REST pour la gestion des paiements * Resource REST pour la gestion des paiements (Wave Checkout et paiements manuels).
*
* <p>Endpoints principaux :
* <ul>
* <li>{@code POST /api/paiements/initier-paiement-en-ligne} — démarre le flux Wave QR code</li>
* <li>{@code GET /api/paiements/statut-intention/{intentionId}} — polling du statut Wave</li>
* <li>{@code POST /api/paiements/declarer-manuel} — paiement manuel (espèces/virement)</li>
* <li>{@code GET /api/paiements/mon-historique} — historique du membre connecté</li>
* </ul>
* *
* @author UnionFlow Team * @author UnionFlow Team
* @version 3.0 * @version 3.0
* @since 2025-01-29 * @since 2026-04-13
*/ */
@Path("/api/paiements") @Path("/api/paiements")
@Produces(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON) @Consumes(MediaType.APPLICATION_JSON)
@RolesAllowed({ "ADMIN", "ADMIN_ORGANISATION", "MEMBRE", "USER" }) @RolesAllowed({"ADMIN", "ADMIN_ORGANISATION", "MEMBRE", "USER"})
@Tag(name = "Paiements", description = "Gestion des paiements : création, validation et suivi") @Tag(name = "Paiements", description = "Paiements de cotisations — Wave Checkout et manuel")
public class PaiementResource { public class PaiementResource {
private static final Logger LOG = Logger.getLogger(PaiementResource.class); private static final Logger LOG = Logger.getLogger(PaiementResource.class);
@@ -34,56 +46,8 @@ public class PaiementResource {
@Inject @Inject
PaiementService paiementService; PaiementService paiementService;
/** // ── Lecture ───────────────────────────────────────────────────────────────
* Crée un nouveau paiement
*
* @param request DTO du paiement à créer
* @return Paiement créé
*/
@POST
@RolesAllowed({ "ADMIN", "ADMIN_ORGANISATION", "MEMBRE" })
public Response creerPaiement(@Valid CreatePaiementRequest request) {
LOG.infof("POST /api/paiements - Création paiement: %s", request.numeroReference());
PaiementResponse result = paiementService.creerPaiement(request);
return Response.status(Response.Status.CREATED).entity(result).build();
}
/**
* Valide un paiement
*
* @param id ID du paiement
* @return Paiement validé
*/
@POST
@RolesAllowed({ "ADMIN", "ADMIN_ORGANISATION", "MEMBRE" })
@Path("/{id}/valider")
public Response validerPaiement(@PathParam("id") UUID id) {
LOG.infof("POST /api/paiements/%s/valider", id);
PaiementResponse result = paiementService.validerPaiement(id);
return Response.ok(result).build();
}
/**
* Annule un paiement
*
* @param id ID du paiement
* @return Paiement annulé
*/
@POST
@RolesAllowed({ "ADMIN", "ADMIN_ORGANISATION", "MEMBRE" })
@Path("/{id}/annuler")
public Response annulerPaiement(@PathParam("id") UUID id) {
LOG.infof("POST /api/paiements/%s/annuler", id);
PaiementResponse result = paiementService.annulerPaiement(id);
return Response.ok(result).build();
}
/**
* Trouve un paiement par son ID
*
* @param id ID du paiement
* @return Paiement
*/
@GET @GET
@Path("/{id}") @Path("/{id}")
public Response trouverParId(@PathParam("id") UUID id) { public Response trouverParId(@PathParam("id") UUID id) {
@@ -92,115 +56,100 @@ public class PaiementResource {
return Response.ok(result).build(); return Response.ok(result).build();
} }
/**
* Trouve un paiement par son numéro de référence
*
* @param numeroReference Numéro de référence
* @return Paiement
*/
@GET @GET
@Path("/reference/{numeroReference}") @Path("/reference/{numeroReference}")
public Response trouverParNumeroReference(@PathParam("numeroReference") String numeroReference) { public Response trouverParNumeroReference(
@PathParam("numeroReference") String numeroReference) {
LOG.infof("GET /api/paiements/reference/%s", numeroReference); LOG.infof("GET /api/paiements/reference/%s", numeroReference);
PaiementResponse result = paiementService.trouverParNumeroReference(numeroReference); PaiementResponse result = paiementService.trouverParNumeroReference(numeroReference);
return Response.ok(result).build(); return Response.ok(result).build();
} }
/**
* Liste tous les paiements d'un membre
*
* @param membreId ID du membre
* @return Liste des paiements
*/
@GET @GET
@Path("/membre/{membreId}") @Path("/membre/{membreId}")
@RolesAllowed({"ADMIN", "ADMIN_ORGANISATION"})
public Response listerParMembre(@PathParam("membreId") UUID membreId) { public Response listerParMembre(@PathParam("membreId") UUID membreId) {
LOG.infof("GET /api/paiements/membre/%s", membreId); LOG.infof("GET /api/paiements/membre/%s", membreId);
List<PaiementSummaryResponse> result = paiementService.listerParMembre(membreId); List<PaiementSummaryResponse> result = paiementService.listerParMembre(membreId);
return Response.ok(result).build(); return Response.ok(result).build();
} }
/**
* Liste l'historique des paiements du membre connecté (auto-détection).
* Utilisé par la page personnelle "Payer mes Cotisations".
*
* @param limit Nombre maximum de paiements à retourner (défaut : 5)
* @return Liste des derniers paiements
*/
@GET @GET
@Path("/mes-paiements/historique") @Path("/mon-historique")
@RolesAllowed({ "MEMBRE", "ADMIN", "ADMIN_ORGANISATION" }) @RolesAllowed({"MEMBRE", "ADMIN", "ADMIN_ORGANISATION"})
public Response getMonHistoriquePaiements( public Response getMonHistoriquePaiements(
@QueryParam("limit") @DefaultValue("5") int limit) { @QueryParam("limit") @DefaultValue("20") int limit) {
LOG.infof("GET /api/paiements/mes-paiements/historique?limit=%d", limit); LOG.infof("GET /api/paiements/mon-historique?limit=%d", limit);
List<PaiementSummaryResponse> result = paiementService.getMonHistoriquePaiements(limit); List<PaiementSummaryResponse> result = paiementService.getMonHistoriquePaiements(limit);
return Response.ok(result).build(); return Response.ok(result).build();
} }
/** // ── Administration ────────────────────────────────────────────────────────
* Initie un paiement en ligne via un gateway (Wave, Orange Money, Free Money, Carte).
* Retourne l'URL de redirection vers le gateway.
*
* @param request Données du paiement en ligne
* @return URL de redirection + transaction ID
*/
@POST @POST
@Path("/initier-paiement-en-ligne") @RolesAllowed({"ADMIN", "ADMIN_ORGANISATION"})
@RolesAllowed({ "MEMBRE", "MEMBRE_ACTIF", "ADMIN", "ADMIN_ORGANISATION", "USER" }) public Response creerPaiement(@Valid CreatePaiementRequest request) {
public Response initierPaiementEnLigne(@Valid dev.lions.unionflow.server.api.dto.paiement.request.InitierPaiementEnLigneRequest request) { LOG.infof("POST /api/paiements — référence: %s", request.numeroReference());
LOG.infof("POST /api/paiements/initier-paiement-en-ligne - cotisation: %s, méthode: %s", PaiementResponse result = paiementService.creerPaiement(request);
request.cotisationId(), request.methodePaiement());
dev.lions.unionflow.server.api.dto.paiement.response.PaiementGatewayResponse result =
paiementService.initierPaiementEnLigne(request);
return Response.status(Response.Status.CREATED).entity(result).build(); return Response.status(Response.Status.CREATED).entity(result).build();
} }
/**
* Initie un dépôt sur compte épargne via Wave (même flux que cotisations).
* Retourne wave_launch_url pour ouvrir l'app Wave puis retour deep link.
*/
@POST @POST
@Path("/initier-depot-epargne-en-ligne") @Path("/{id}/valider")
@RolesAllowed({ "MEMBRE", "MEMBRE_ACTIF", "ADMIN", "ADMIN_ORGANISATION", "USER" }) @RolesAllowed({"ADMIN", "ADMIN_ORGANISATION"})
public Response initierDepotEpargneEnLigne(@Valid dev.lions.unionflow.server.api.dto.paiement.request.InitierDepotEpargneRequest request) { public Response validerPaiement(@PathParam("id") UUID id) {
LOG.infof("POST /api/paiements/initier-depot-epargne-en-ligne - compte: %s, montant: %s", LOG.infof("POST /api/paiements/%s/valider", id);
request.compteId(), request.montant()); PaiementResponse result = paiementService.validerPaiement(id);
dev.lions.unionflow.server.api.dto.paiement.response.PaiementGatewayResponse result =
paiementService.initierDepotEpargneEnLigne(request);
return Response.status(Response.Status.CREATED).entity(result).build();
}
/**
* Polling du statut d'une IntentionPaiement Wave.
* Si Wave a confirmé le paiement, réconcilie automatiquement la cotisation (PAYEE) et retourne COMPLETEE.
* Le client web appelle cet endpoint toutes les 3 secondes pendant l'affichage du QR code.
*
* @param intentionId UUID de l'intention (clientReference retourné par initier-paiement-en-ligne)
* @return Statut courant + waveLaunchUrl (pour re-générer le QR si besoin) + message
*/
@GET
@Path("/statut-intention/{intentionId}")
@RolesAllowed({ "MEMBRE", "MEMBRE_ACTIF", "ADMIN", "ADMIN_ORGANISATION", "USER" })
public Response getStatutIntention(@PathParam("intentionId") java.util.UUID intentionId) {
LOG.infof("GET /api/paiements/statut-intention/%s", intentionId);
dev.lions.unionflow.server.api.dto.paiement.response.IntentionStatutResponse result =
paiementService.verifierStatutIntention(intentionId);
return Response.ok(result).build(); return Response.ok(result).build();
} }
@POST
@Path("/{id}/annuler")
@RolesAllowed({"ADMIN", "ADMIN_ORGANISATION", "MEMBRE"})
public Response annulerPaiement(@PathParam("id") UUID id) {
LOG.infof("POST /api/paiements/%s/annuler", id);
PaiementResponse result = paiementService.annulerPaiement(id);
return Response.ok(result).build();
}
// ── Flux Wave Checkout (QR code web) ──────────────────────────────────────
/** /**
* Déclare un paiement manuel (espèces, virement, chèque). * Initie un paiement Wave via Checkout QR code.
* Le paiement est créé avec le statut EN_ATTENTE_VALIDATION. * Le web encode le {@code waveLaunchUrl} en QR code, l'utilisateur le scanne
* Le trésorier devra le valider via une page admin. * depuis l'app Wave. Après confirmation, Wave redirige vers la success URL.
*
* @param request Données du paiement manuel
* @return Paiement créé (statut EN_ATTENTE_VALIDATION)
*/ */
@POST @POST
@Path("/declarer-paiement-manuel") @Path("/initier-paiement-en-ligne")
@RolesAllowed({ "MEMBRE", "ADMIN", "ADMIN_ORGANISATION" }) @RolesAllowed({"MEMBRE", "ADMIN", "ADMIN_ORGANISATION", "USER"})
public Response declarerPaiementManuel(@Valid dev.lions.unionflow.server.api.dto.paiement.request.DeclarerPaiementManuelRequest request) { public Response initierPaiementEnLigne(@Valid InitierPaiementEnLigneRequest request) {
LOG.infof("POST /api/paiements/declarer-paiement-manuel - cotisation: %s, méthode: %s", LOG.infof("POST /api/paiements/initier-paiement-en-ligne — cotisation: %s, méthode: %s",
request.cotisationId(), request.methodePaiement());
PaiementGatewayResponse result = paiementService.initierPaiementEnLigne(request);
return Response.status(Response.Status.CREATED).entity(result).build();
}
/**
* Polling du statut d'une intention de paiement Wave.
* Appelé toutes les 3 secondes par le web pendant que l'utilisateur scanne le QR code.
* Retourne {@code confirme=true} dès que Wave confirme le paiement.
*/
@GET
@Path("/statut-intention/{intentionId}")
@RolesAllowed({"MEMBRE", "ADMIN", "ADMIN_ORGANISATION", "USER"})
public Response getStatutIntention(@PathParam("intentionId") UUID intentionId) {
LOG.infof("GET /api/paiements/statut-intention/%s", intentionId);
IntentionStatutResponse result = paiementService.getStatutIntention(intentionId);
return Response.ok(result).build();
}
// ── Flux manuel ───────────────────────────────────────────────────────────
@POST
@Path("/declarer-manuel")
@RolesAllowed({"MEMBRE", "ADMIN", "ADMIN_ORGANISATION"})
public Response declarerPaiementManuel(@Valid DeclarerPaiementManuelRequest request) {
LOG.infof("POST /api/paiements/declarer-manuel — cotisation: %s, méthode: %s",
request.cotisationId(), request.methodePaiement()); request.cotisationId(), request.methodePaiement());
PaiementResponse result = paiementService.declarerPaiementManuel(request); PaiementResponse result = paiementService.declarerPaiementManuel(request);
return Response.status(Response.Status.CREATED).entity(result).build(); return Response.status(Response.Status.CREATED).entity(result).build();

View File

@@ -1,6 +1,5 @@
package dev.lions.unionflow.server.service; package dev.lions.unionflow.server.service;
import dev.lions.unionflow.server.api.dto.notification.request.CreateNotificationRequest;
import dev.lions.unionflow.server.api.dto.paiement.request.CreatePaiementRequest; import dev.lions.unionflow.server.api.dto.paiement.request.CreatePaiementRequest;
import dev.lions.unionflow.server.api.dto.paiement.response.PaiementResponse; import dev.lions.unionflow.server.api.dto.paiement.response.PaiementResponse;
import dev.lions.unionflow.server.api.dto.paiement.response.PaiementSummaryResponse; import dev.lions.unionflow.server.api.dto.paiement.response.PaiementSummaryResponse;
@@ -17,7 +16,6 @@ import dev.lions.unionflow.server.repository.MembreRepository;
import dev.lions.unionflow.server.repository.PaiementRepository; import dev.lions.unionflow.server.repository.PaiementRepository;
import dev.lions.unionflow.server.repository.mutuelle.epargne.CompteEpargneRepository; import dev.lions.unionflow.server.repository.mutuelle.epargne.CompteEpargneRepository;
import dev.lions.unionflow.server.repository.TypeReferenceRepository; import dev.lions.unionflow.server.repository.TypeReferenceRepository;
import dev.lions.unionflow.server.repository.MembreOrganisationRepository;
import dev.lions.unionflow.server.service.WaveCheckoutService.WaveCheckoutException; import dev.lions.unionflow.server.service.WaveCheckoutService.WaveCheckoutException;
import dev.lions.unionflow.server.service.WaveCheckoutService.WaveCheckoutSessionResponse; import dev.lions.unionflow.server.service.WaveCheckoutService.WaveCheckoutSessionResponse;
import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.context.ApplicationScoped;
@@ -65,12 +63,6 @@ public class PaiementService {
@Inject @Inject
CompteEpargneRepository compteEpargneRepository; CompteEpargneRepository compteEpargneRepository;
@Inject
MembreOrganisationRepository membreOrganisationRepository;
@Inject
NotificationService notificationService;
@Inject @Inject
io.quarkus.security.identity.SecurityIdentity securityIdentity; io.quarkus.security.identity.SecurityIdentity securityIdentity;
@@ -333,11 +325,7 @@ public class PaiementService {
.build(); .build();
intentionPaiementRepository.persist(intention); intentionPaiementRepository.persist(intention);
// Web (sans numéro de téléphone) → page HTML de confirmation ; Mobile → deep link app String successUrl = base + "/api/wave-redirect/success?ref=" + intention.getId();
boolean isWebContext = request.numeroTelephone() == null || request.numeroTelephone().isBlank();
String successUrl = base + (isWebContext
? "/api/wave-redirect/web-success?ref=" + intention.getId()
: "/api/wave-redirect/success?ref=" + intention.getId());
String errorUrl = base + "/api/wave-redirect/error?ref=" + intention.getId(); String errorUrl = base + "/api/wave-redirect/error?ref=" + intention.getId();
String clientRef = intention.getId().toString(); String clientRef = intention.getId().toString();
// XOF : montant entier, pas de décimales (spec Wave) // XOF : montant entier, pas de décimales (spec Wave)
@@ -397,113 +385,6 @@ public class PaiementService {
.build(); .build();
} }
/**
* Vérifie le statut d'une IntentionPaiement Wave.
* Si la session Wave est complétée (paiement réussi), réconcilie automatiquement
* la cotisation (marque PAYEE) et met à jour l'intention (COMPLETEE).
* Appelé en polling depuis le web toutes les 3 secondes.
*/
@Transactional
public dev.lions.unionflow.server.api.dto.paiement.response.IntentionStatutResponse verifierStatutIntention(UUID intentionId) {
IntentionPaiement intention = intentionPaiementRepository.findById(intentionId);
if (intention == null) {
throw new NotFoundException("IntentionPaiement non trouvée: " + intentionId);
}
// Déjà terminée — retourner immédiatement
if (intention.isCompletee()) {
return buildStatutResponse(intention, "Paiement confirmé !");
}
if (StatutIntentionPaiement.EXPIREE.equals(intention.getStatut())
|| StatutIntentionPaiement.ECHOUEE.equals(intention.getStatut())) {
return buildStatutResponse(intention, "Paiement " + intention.getStatut().name().toLowerCase());
}
// Session expirée côté UnionFlow (30 min)
if (intention.isExpiree()) {
intention.setStatut(StatutIntentionPaiement.EXPIREE);
intentionPaiementRepository.persist(intention);
return buildStatutResponse(intention, "Session expirée, veuillez recommencer");
}
// Vérifier le statut côté Wave si session connue
if (intention.getWaveCheckoutSessionId() != null) {
try {
WaveCheckoutService.WaveSessionStatusResponse waveStatus =
waveCheckoutService.getSession(intention.getWaveCheckoutSessionId());
if (waveStatus.isSucceeded()) {
completerIntention(intention, waveStatus.transactionId);
return buildStatutResponse(intention, "Paiement 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 poll",
intention.getWaveCheckoutSessionId());
}
}
return buildStatutResponse(intention, "En attente de confirmation Wave...");
}
/**
* Marque l'IntentionPaiement COMPLETEE et réconcilie les cotisations cibles (PAYEE).
* Utilisé par le polling web ET par WaveRedirectResource lors du redirect success.
*/
@Transactional
public void completerIntention(IntentionPaiement intention, String waveTransactionId) {
if (intention.isCompletee()) return; // idempotent
intention.setStatut(StatutIntentionPaiement.COMPLETEE);
intention.setDateCompletion(java.time.LocalDateTime.now());
if (waveTransactionId != null) intention.setWaveTransactionId(waveTransactionId);
intentionPaiementRepository.persist(intention);
// Réconcilier les cotisations listées dans objetsCibles
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());
java.math.BigDecimal montant = node.has("montant")
? new java.math.BigDecimal(node.get("montant").asText())
: intention.getMontantTotal();
Cotisation cotisation = paiementRepository.getEntityManager().find(Cotisation.class, cotisationId);
if (cotisation == null) continue;
cotisation.setMontantPaye(montant);
cotisation.setStatut("PAYEE");
cotisation.setDatePaiement(java.time.LocalDateTime.now());
paiementRepository.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());
}
}
private dev.lions.unionflow.server.api.dto.paiement.response.IntentionStatutResponse buildStatutResponse(
IntentionPaiement intention, String message) {
return dev.lions.unionflow.server.api.dto.paiement.response.IntentionStatutResponse.builder()
.intentionId(intention.getId())
.statut(intention.getStatut().name())
.waveLaunchUrl(intention.getWaveLaunchUrl())
.waveCheckoutSessionId(intention.getWaveCheckoutSessionId())
.waveTransactionId(intention.getWaveTransactionId())
.montant(intention.getMontantTotal())
.message(message)
.build();
}
/** Format E.164 pour Wave (ex: 771234567 -> +225771234567). */ /** Format E.164 pour Wave (ex: 771234567 -> +225771234567). */
private static String toE164(String numeroTelephone) { private static String toE164(String numeroTelephone) {
if (numeroTelephone == null || numeroTelephone.isBlank()) return null; if (numeroTelephone == null || numeroTelephone.isBlank()) return null;
@@ -628,21 +509,13 @@ public class PaiementService {
paiementRepository.persist(paiement); paiementRepository.persist(paiement);
// Notifier l'admin de l'organisation pour validation du paiement manuel // TODO: Créer une notification pour le trésorier
membreOrganisationRepository.findFirstByMembreId(membreConnecte.getId()) // notificationService.creerNotification(
.ifPresent(mo -> { // "VALIDATION_PAIEMENT_REQUIS",
CreateNotificationRequest notif = CreateNotificationRequest.builder() // "Validation paiement manuel requis",
.typeNotification("VALIDATION_PAIEMENT_REQUIS") // "Le membre " + membreConnecte.getNumeroMembre() + " a déclaré un paiement manuel à valider.",
.priorite("HAUTE") // tresorierIds
.sujet("Validation paiement manuel requis") // );
.corps("Le membre " + membreConnecte.getNumeroMembre()
+ " a déclaré un paiement manuel de " + paiement.getMontant()
+ " XOF (réf: " + paiement.getNumeroReference() + ") à valider.")
.organisationId(mo.getOrganisation().getId())
.build();
notificationService.creerNotification(notif);
LOG.infof("Notification de validation envoyée pour l'organisation %s", mo.getOrganisation().getId());
});
LOG.infof("Paiement manuel déclaré avec succès: ID=%s, Référence=%s (EN_ATTENTE_VALIDATION)", LOG.infof("Paiement manuel déclaré avec succès: ID=%s, Référence=%s (EN_ATTENTE_VALIDATION)",
paiement.getId(), paiement.getNumeroReference()); paiement.getId(), paiement.getNumeroReference());
@@ -650,6 +523,69 @@ public class PaiementService {
return convertToResponse(paiement); return convertToResponse(paiement);
} }
// ── Polling statut intention ──────────────────────────────────────────────
/**
* Retourne le statut d'une intention de paiement Wave.
* Utilisé par le polling web (QR code) et le deep link mobile.
*/
@Transactional
public dev.lions.unionflow.server.api.dto.paiement.response.IntentionStatutResponse getStatutIntention(UUID intentionId) {
IntentionPaiement intention = intentionPaiementRepository.findById(intentionId);
if (intention == null) {
throw new NotFoundException("Intention de paiement non trouvée : " + intentionId);
}
if (intention.isCompletee()) {
return buildIntentionStatutResponse(intention, "Paiement confirmé !");
}
if (StatutIntentionPaiement.EXPIREE.equals(intention.getStatut())
|| StatutIntentionPaiement.ECHOUEE.equals(intention.getStatut())) {
return buildIntentionStatutResponse(intention,
"Paiement " + intention.getStatut().name().toLowerCase());
}
if (intention.isExpiree()) {
intention.setStatut(StatutIntentionPaiement.EXPIREE);
intentionPaiementRepository.persist(intention);
return buildIntentionStatutResponse(intention, "Session expirée, veuillez recommencer");
}
if (intention.getWaveCheckoutSessionId() != null) {
try {
WaveCheckoutService.WaveSessionStatusResponse waveStatus =
waveCheckoutService.getSession(intention.getWaveCheckoutSessionId());
if (waveStatus.isSucceeded()) {
intention.setStatut(StatutIntentionPaiement.COMPLETEE);
intentionPaiementRepository.persist(intention);
return buildIntentionStatutResponse(intention, "Paiement confirmé !");
} else if (waveStatus.isExpired()) {
intention.setStatut(StatutIntentionPaiement.EXPIREE);
intentionPaiementRepository.persist(intention);
return buildIntentionStatutResponse(intention, "Session Wave expirée");
}
} catch (WaveCheckoutException e) {
LOG.warnf(e, "Impossible de vérifier la session Wave %s",
intention.getWaveCheckoutSessionId());
}
}
return buildIntentionStatutResponse(intention, "En attente de confirmation Wave...");
}
private dev.lions.unionflow.server.api.dto.paiement.response.IntentionStatutResponse buildIntentionStatutResponse(
IntentionPaiement intention, String message) {
return dev.lions.unionflow.server.api.dto.paiement.response.IntentionStatutResponse.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();
}
// ======================================== // ========================================
// MÉTHODES PRIVÉES // MÉTHODES PRIVÉES
// ======================================== // ========================================
@@ -672,6 +608,10 @@ public class PaiementService {
/** Convertit une entité en Response DTO */ /** Convertit une entité en Response DTO */
private PaiementResponse convertToResponse(Paiement paiement) { private PaiementResponse convertToResponse(Paiement paiement) {
if (paiement == null) {
return null;
}
PaiementResponse response = new PaiementResponse(); PaiementResponse response = new PaiementResponse();
response.setId(paiement.getId()); response.setId(paiement.getId());
response.setNumeroReference(paiement.getNumeroReference()); response.setNumeroReference(paiement.getNumeroReference());

View File

@@ -0,0 +1,13 @@
-- V25 : Rendre numero_transaction nullable dans la table paiements
--
-- Problème : la colonne numero_transaction est définie NOT NULL dans V1,
-- mais l'entité Paiement ne la mappe pas. Un paiement Wave créé en état
-- EN_ATTENTE n'a pas encore de numéro de transaction (celui-ci arrive via
-- le callback Wave après complétion). La contrainte NOT NULL empêche tout
-- INSERT et provoque un 500.
--
-- La colonne transaction_wave_id stocke déjà le session ID Wave ;
-- numero_transaction est distinct (ID de transaction finalisée chez Wave).
ALTER TABLE paiements
ALTER COLUMN numero_transaction DROP NOT NULL;

View File

@@ -0,0 +1,16 @@
-- V26 : Correction des colonnes legacy NOT NULL non mappées dans l'entité Paiement
--
-- Contexte : La table paiements a été créée par V1 avec des colonnes NOT NULL
-- (statut, type_paiement). Hibernate en mode "update" a ensuite ajouté les colonnes
-- métier réelles (statut_paiement, methode_paiement...) sans supprimer les anciennes.
-- Résultat : l'entité Paiement ne mappe pas ces colonnes V1, et tout INSERT échoue.
--
-- Solutions :
-- - type_paiement : équivalent fonctionnel de methode_paiement → nullable
-- - statut : remplacé par statut_paiement dans l'entité → nullable
ALTER TABLE paiements
ALTER COLUMN type_paiement DROP NOT NULL;
ALTER TABLE paiements
ALTER COLUMN statut DROP NOT NULL;

View File

@@ -1,109 +0,0 @@
package dev.lions.unionflow.server.entity;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("PaiementObjet")
class PaiementObjetTest {
private static Paiement newPaiement() {
Membre m = new Membre();
m.setId(UUID.randomUUID());
m.setNumeroMembre("M1");
m.setPrenom("A");
m.setNom("B");
m.setEmail("a@test.com");
m.setDateNaissance(java.time.LocalDate.now());
Paiement p = new Paiement();
p.setId(UUID.randomUUID());
p.setNumeroReference("PAY-1");
p.setMontant(BigDecimal.TEN);
p.setCodeDevise("XOF");
p.setMethodePaiement("WAVE");
p.setMembre(m);
return p;
}
@Test
@DisplayName("getters/setters")
void gettersSetters() {
PaiementObjet po = new PaiementObjet();
po.setPaiement(newPaiement());
po.setTypeObjetCible("COTISATION");
po.setObjetCibleId(UUID.randomUUID());
po.setMontantApplique(new BigDecimal("5000.00"));
po.setDateApplication(LocalDateTime.now());
po.setCommentaire("Cotisation janvier");
assertThat(po.getTypeObjetCible()).isEqualTo("COTISATION");
assertThat(po.getMontantApplique()).isEqualByComparingTo("5000.00");
assertThat(po.getCommentaire()).isEqualTo("Cotisation janvier");
}
@Test
@DisplayName("equals et hashCode")
void equalsHashCode() {
UUID id = UUID.randomUUID();
UUID objId = UUID.randomUUID();
Paiement p = newPaiement();
PaiementObjet a = new PaiementObjet();
a.setId(id);
a.setPaiement(p);
a.setTypeObjetCible("COTISATION");
a.setObjetCibleId(objId);
a.setMontantApplique(BigDecimal.ONE);
PaiementObjet b = new PaiementObjet();
b.setId(id);
b.setPaiement(p);
b.setTypeObjetCible("COTISATION");
b.setObjetCibleId(objId);
b.setMontantApplique(BigDecimal.ONE);
assertThat(a).isEqualTo(b);
assertThat(a.hashCode()).isEqualTo(b.hashCode());
}
@Test
@DisplayName("toString non null")
void toString_nonNull() {
PaiementObjet po = new PaiementObjet();
po.setPaiement(newPaiement());
po.setTypeObjetCible("COTISATION");
po.setObjetCibleId(UUID.randomUUID());
po.setMontantApplique(BigDecimal.ONE);
assertThat(po.toString()).isNotNull().isNotEmpty();
}
@Test
@DisplayName("onCreate initialise dateApplication si null")
void onCreate_setsDateApplicationWhenNull() {
PaiementObjet po = new PaiementObjet();
po.setPaiement(newPaiement());
po.setTypeObjetCible("COTISATION");
po.setObjetCibleId(UUID.randomUUID());
po.setMontantApplique(BigDecimal.ONE);
// dateApplication est null
po.onCreate();
assertThat(po.getDateApplication()).isNotNull();
assertThat(po.getActif()).isTrue();
}
@Test
@DisplayName("onCreate ne remplace pas une dateApplication existante")
void onCreate_doesNotOverrideDateApplication() {
LocalDateTime fixed = LocalDateTime.of(2026, 1, 1, 0, 0);
PaiementObjet po = new PaiementObjet();
po.setDateApplication(fixed);
po.onCreate();
assertThat(po.getDateApplication()).isEqualTo(fixed);
}
}

View File

@@ -1,101 +0,0 @@
package dev.lions.unionflow.server.entity;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("Paiement")
class PaiementTest {
private static Membre newMembre() {
Membre m = new Membre();
m.setId(UUID.randomUUID());
m.setNumeroMembre("M1");
m.setPrenom("A");
m.setNom("B");
m.setEmail("a@test.com");
m.setDateNaissance(java.time.LocalDate.now());
return m;
}
@Test
@DisplayName("getters/setters")
void gettersSetters() {
Paiement p = new Paiement();
p.setNumeroReference("PAY-2025-001");
p.setMontant(new BigDecimal("10000.00"));
p.setCodeDevise("XOF");
p.setMethodePaiement("WAVE");
p.setStatutPaiement("VALIDE");
p.setDatePaiement(LocalDateTime.now());
p.setMembre(newMembre());
assertThat(p.getNumeroReference()).isEqualTo("PAY-2025-001");
assertThat(p.getMontant()).isEqualByComparingTo("10000.00");
assertThat(p.getCodeDevise()).isEqualTo("XOF");
assertThat(p.getStatutPaiement()).isEqualTo("VALIDE");
}
@Test
@DisplayName("genererNumeroReference")
void genererNumeroReference() {
String ref = Paiement.genererNumeroReference();
assertThat(ref).startsWith("PAY-").isNotNull();
}
@Test
@DisplayName("isValide et peutEtreModifie")
void isValide_peutEtreModifie() {
Paiement p = new Paiement();
p.setNumeroReference("X");
p.setMontant(BigDecimal.ONE);
p.setCodeDevise("XOF");
p.setMethodePaiement("WAVE");
p.setMembre(newMembre());
p.setStatutPaiement("VALIDE");
assertThat(p.isValide()).isTrue();
assertThat(p.peutEtreModifie()).isFalse();
p.setStatutPaiement("EN_ATTENTE");
assertThat(p.peutEtreModifie()).isTrue();
}
@Test
@DisplayName("equals et hashCode")
void equalsHashCode() {
UUID id = UUID.randomUUID();
Membre m = newMembre();
Paiement a = new Paiement();
a.setId(id);
a.setNumeroReference("REF-1");
a.setMontant(BigDecimal.ONE);
a.setCodeDevise("XOF");
a.setMethodePaiement("WAVE");
a.setMembre(m);
Paiement b = new Paiement();
b.setId(id);
b.setNumeroReference("REF-1");
b.setMontant(BigDecimal.ONE);
b.setCodeDevise("XOF");
b.setMethodePaiement("WAVE");
b.setMembre(m);
assertThat(a).isEqualTo(b);
assertThat(a.hashCode()).isEqualTo(b.hashCode());
}
@Test
@DisplayName("toString non null")
void toString_nonNull() {
Paiement p = new Paiement();
p.setNumeroReference("X");
p.setMontant(BigDecimal.ONE);
p.setCodeDevise("XOF");
p.setMethodePaiement("WAVE");
p.setMembre(newMembre());
assertThat(p.toString()).isNotNull().isNotEmpty();
}
}

View File

@@ -1,107 +0,0 @@
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.Paiement;
import io.quarkus.test.junit.QuarkusTest;
import io.quarkus.test.TestTransaction;
import jakarta.inject.Inject;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
@QuarkusTest
class PaiementRepositoryTest {
@Inject
PaiementRepository paiementRepository;
@Test
@TestTransaction
@DisplayName("findById retourne null pour UUID inexistant")
void findById_inexistant_returnsNull() {
assertThat(paiementRepository.findById(UUID.randomUUID())).isNull();
}
@Test
@TestTransaction
@DisplayName("findPaiementById retourne empty pour UUID inexistant")
void findPaiementById_inexistant_returnsEmpty() {
Optional<Paiement> opt = paiementRepository.findPaiementById(UUID.randomUUID());
assertThat(opt).isEmpty();
}
@Test
@TestTransaction
@DisplayName("findByNumeroReference retourne empty pour référence inexistante")
void findByNumeroReference_inexistant_returnsEmpty() {
Optional<Paiement> opt = paiementRepository.findByNumeroReference("REF-" + UUID.randomUUID());
assertThat(opt).isEmpty();
}
@Test
@TestTransaction
@DisplayName("listAll retourne une liste")
void listAll_returnsList() {
List<Paiement> list = paiementRepository.listAll();
assertThat(list).isNotNull();
}
@Test
@TestTransaction
@DisplayName("count retourne un nombre >= 0")
void count_returnsNonNegative() {
assertThat(paiementRepository.count()).isGreaterThanOrEqualTo(0L);
}
@Test
@TestTransaction
@DisplayName("findByStatut retourne une liste (vide si aucun paiement avec ce statut)")
void findByStatut_returnsEmptyList() {
List<Paiement> list = paiementRepository.findByStatut(StatutPaiement.EN_ATTENTE);
assertThat(list).isNotNull();
}
@Test
@TestTransaction
@DisplayName("findByMethode retourne une liste (vide si aucun paiement avec cette méthode)")
void findByMethode_returnsEmptyList() {
List<Paiement> list = paiementRepository.findByMethode(MethodePaiement.VIREMENT_BANCAIRE);
assertThat(list).isNotNull();
}
@Test
@TestTransaction
@DisplayName("findValidesParPeriode retourne une liste pour une période donnée")
void findValidesParPeriode_returnsEmptyList() {
LocalDateTime debut = LocalDateTime.now().minusDays(30);
LocalDateTime fin = LocalDateTime.now();
List<Paiement> list = paiementRepository.findValidesParPeriode(debut, fin);
assertThat(list).isNotNull();
}
@Test
@TestTransaction
@DisplayName("calculerMontantTotalValides retourne ZERO si aucun paiement validé")
void calculerMontantTotalValides_returnsZero() {
LocalDateTime debut = LocalDateTime.now().minusDays(1);
LocalDateTime fin = LocalDateTime.now();
BigDecimal total = paiementRepository.calculerMontantTotalValides(debut, fin);
assertThat(total).isGreaterThanOrEqualTo(BigDecimal.ZERO);
}
@Test
@TestTransaction
@DisplayName("findByMembreId retourne une liste (vide si aucun paiement pour ce membre)")
void findByMembreId_returnsEmptyList() {
List<Paiement> list = paiementRepository.findByMembreId(UUID.randomUUID());
assertThat(list).isNotNull();
}
}

View File

@@ -1,459 +0,0 @@
package dev.lions.unionflow.server.resource;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.anyOf;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.notNullValue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.when;
import dev.lions.unionflow.server.api.dto.paiement.response.PaiementGatewayResponse;
import dev.lions.unionflow.server.api.dto.paiement.response.PaiementResponse;
import dev.lions.unionflow.server.api.dto.paiement.response.PaiementSummaryResponse;
import dev.lions.unionflow.server.service.PaiementService;
import io.quarkus.test.InjectMock;
import io.quarkus.test.junit.QuarkusTest;
import io.quarkus.test.security.TestSecurity;
import io.restassured.http.ContentType;
import jakarta.ws.rs.NotFoundException;
import java.util.Collections;
import java.util.List;
import java.util.UUID;
import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestMethodOrder;
/**
* Tests d'intégration REST pour PaiementResource.
*
* @author UnionFlow Team
* @version 2.0
* @since 2026-03-21
*/
@QuarkusTest
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class PaiementResourceTest {
private static final String BASE_PATH = "/api/paiements";
private static final String PAIEMENT_ID = "00000000-0000-0000-0000-000000000010";
private static final String MEMBRE_ID = "00000000-0000-0000-0000-000000000020";
@InjectMock
PaiementService paiementService;
// -------------------------------------------------------------------------
// GET /api/paiements/{id}
// -------------------------------------------------------------------------
@Test
@Order(1)
@TestSecurity(user = "admin@unionflow.com", roles = {"ADMIN"})
void trouverParId_found_returns200() {
PaiementResponse response = PaiementResponse.builder()
.numeroReference("PAY-001")
.build();
when(paiementService.trouverParId(any(UUID.class))).thenReturn(response);
given()
.pathParam("id", PAIEMENT_ID)
.when()
.get(BASE_PATH + "/{id}")
.then()
.statusCode(200)
.contentType(ContentType.JSON)
.body("$", notNullValue());
}
@Test
@Order(2)
@TestSecurity(user = "admin@unionflow.com", roles = {"ADMIN"})
void trouverParId_notFound_returns404() {
when(paiementService.trouverParId(any(UUID.class)))
.thenThrow(new NotFoundException("Paiement non trouvé"));
given()
.pathParam("id", UUID.randomUUID())
.when()
.get(BASE_PATH + "/{id}")
.then()
.statusCode(404);
}
// -------------------------------------------------------------------------
// GET /api/paiements/reference/{numeroReference}
// -------------------------------------------------------------------------
@Test
@Order(3)
@TestSecurity(user = "admin@unionflow.com", roles = {"ADMIN"})
void trouverParNumeroReference_found_returns200() {
PaiementResponse response = PaiementResponse.builder()
.numeroReference("PAY-REF-001")
.build();
when(paiementService.trouverParNumeroReference(anyString())).thenReturn(response);
given()
.pathParam("numeroReference", "PAY-REF-001")
.when()
.get(BASE_PATH + "/reference/{numeroReference}")
.then()
.statusCode(200)
.contentType(ContentType.JSON);
}
@Test
@Order(4)
@TestSecurity(user = "admin@unionflow.com", roles = {"ADMIN"})
void trouverParNumeroReference_notFound_returns404() {
when(paiementService.trouverParNumeroReference(anyString()))
.thenThrow(new NotFoundException("Référence non trouvée"));
given()
.pathParam("numeroReference", "PAY-INEXISTANT-99999")
.when()
.get(BASE_PATH + "/reference/{numeroReference}")
.then()
.statusCode(404);
}
// -------------------------------------------------------------------------
// GET /api/paiements/membre/{membreId}
// -------------------------------------------------------------------------
@Test
@Order(5)
@TestSecurity(user = "admin@unionflow.com", roles = {"ADMIN"})
void listerParMembre_returns200() {
when(paiementService.listerParMembre(any(UUID.class)))
.thenReturn(Collections.emptyList());
given()
.pathParam("membreId", MEMBRE_ID)
.when()
.get(BASE_PATH + "/membre/{membreId}")
.then()
.statusCode(200)
.body("$", notNullValue());
}
// -------------------------------------------------------------------------
// GET /api/paiements/mes-paiements/historique
// -------------------------------------------------------------------------
@Test
@Order(6)
@TestSecurity(user = "membre@unionflow.com", roles = {"MEMBRE"})
void getMonHistoriquePaiements_returns200() {
List<PaiementSummaryResponse> history = Collections.emptyList();
when(paiementService.getMonHistoriquePaiements(anyInt())).thenReturn(history);
given()
.queryParam("limit", 5)
.when()
.get(BASE_PATH + "/mes-paiements/historique")
.then()
.statusCode(200)
.body("$", notNullValue());
}
@Test
@Order(7)
@TestSecurity(user = "membre@unionflow.com", roles = {"MEMBRE"})
void getMonHistoriquePaiements_defaultLimit_returns200() {
when(paiementService.getMonHistoriquePaiements(anyInt())).thenReturn(Collections.emptyList());
given()
.when()
.get(BASE_PATH + "/mes-paiements/historique")
.then()
.statusCode(200);
}
// -------------------------------------------------------------------------
// POST /api/paiements
// -------------------------------------------------------------------------
@Test
@Order(8)
@TestSecurity(user = "admin@unionflow.com", roles = {"ADMIN"})
void creerPaiement_success_returns201() {
PaiementResponse response = PaiementResponse.builder()
.numeroReference("PAY-NEW-001")
.build();
when(paiementService.creerPaiement(any())).thenReturn(response);
String body = String.format("""
{
"membreId": "%s",
"montant": 10000,
"numeroReference": "PAY-NEW-001",
"methodePaiement": "ESPECES",
"codeDevise": "XOF"
}
""", MEMBRE_ID);
given()
.contentType(ContentType.JSON)
.body(body)
.when()
.post(BASE_PATH)
.then()
.statusCode(anyOf(equalTo(201), equalTo(400)));
}
@Test
@Order(9)
@TestSecurity(user = "admin@unionflow.com", roles = {"ADMIN"})
void creerPaiement_serverError_returns500() {
when(paiementService.creerPaiement(any()))
.thenThrow(new RuntimeException("db error"));
String body = String.format("""
{"membreId": "%s", "montant": 10000, "numeroReference": "PAY-ERR", "methodePaiement": "ESPECES", "codeDevise": "XOF"}
""", MEMBRE_ID);
given()
.contentType(ContentType.JSON)
.body(body)
.when()
.post(BASE_PATH)
.then()
.statusCode(anyOf(equalTo(500), equalTo(400)));
}
// -------------------------------------------------------------------------
// POST /api/paiements/{id}/valider
// -------------------------------------------------------------------------
@Test
@Order(10)
@TestSecurity(user = "admin@unionflow.com", roles = {"ADMIN"})
void validerPaiement_success_returns200() {
PaiementResponse response = PaiementResponse.builder()
.numeroReference("PAY-001")
.build();
when(paiementService.validerPaiement(any(UUID.class))).thenReturn(response);
given()
.pathParam("id", PAIEMENT_ID)
.contentType(ContentType.JSON)
.when()
.post(BASE_PATH + "/{id}/valider")
.then()
.statusCode(200)
.contentType(ContentType.JSON);
}
@Test
@Order(11)
@TestSecurity(user = "admin@unionflow.com", roles = {"ADMIN"})
void validerPaiement_notFound_returns404() {
when(paiementService.validerPaiement(any(UUID.class)))
.thenThrow(new NotFoundException("Paiement non trouvé"));
given()
.pathParam("id", UUID.randomUUID())
.contentType(ContentType.JSON)
.when()
.post(BASE_PATH + "/{id}/valider")
.then()
.statusCode(404);
}
// -------------------------------------------------------------------------
// POST /api/paiements/{id}/annuler
// -------------------------------------------------------------------------
@Test
@Order(12)
@TestSecurity(user = "admin@unionflow.com", roles = {"ADMIN"})
void annulerPaiement_success_returns200() {
PaiementResponse response = PaiementResponse.builder()
.numeroReference("PAY-001")
.build();
when(paiementService.annulerPaiement(any(UUID.class))).thenReturn(response);
given()
.pathParam("id", PAIEMENT_ID)
.contentType(ContentType.JSON)
.when()
.post(BASE_PATH + "/{id}/annuler")
.then()
.statusCode(200)
.contentType(ContentType.JSON);
}
@Test
@Order(13)
@TestSecurity(user = "admin@unionflow.com", roles = {"ADMIN"})
void annulerPaiement_notFound_returns404() {
when(paiementService.annulerPaiement(any(UUID.class)))
.thenThrow(new NotFoundException("Paiement non trouvé"));
given()
.pathParam("id", UUID.randomUUID())
.contentType(ContentType.JSON)
.when()
.post(BASE_PATH + "/{id}/annuler")
.then()
.statusCode(404);
}
// -------------------------------------------------------------------------
// POST /api/paiements/initier-paiement-en-ligne
// -------------------------------------------------------------------------
@Test
@Order(14)
@TestSecurity(user = "membre@unionflow.com", roles = {"MEMBRE"})
void initierPaiementEnLigne_success_returns201() {
PaiementGatewayResponse response = PaiementGatewayResponse.builder()
.transactionId(UUID.randomUUID())
.redirectUrl("https://wave.example.com/pay/TXN-001")
.build();
when(paiementService.initierPaiementEnLigne(any())).thenReturn(response);
String body = String.format("""
{
"cotisationId": "%s",
"methodePaiement": "WAVE",
"numeroTelephone": "771234567"
}
""", UUID.randomUUID());
given()
.contentType(ContentType.JSON)
.body(body)
.when()
.post(BASE_PATH + "/initier-paiement-en-ligne")
.then()
.statusCode(anyOf(equalTo(201), equalTo(400)));
}
@Test
@Order(15)
@TestSecurity(user = "membre@unionflow.com", roles = {"MEMBRE"})
void initierPaiementEnLigne_serverError_returns500() {
when(paiementService.initierPaiementEnLigne(any()))
.thenThrow(new RuntimeException("gateway error"));
String body = String.format("""
{"cotisationId": "%s", "methodePaiement": "WAVE", "numeroTelephone": "771234567"}
""", UUID.randomUUID());
given()
.contentType(ContentType.JSON)
.body(body)
.when()
.post(BASE_PATH + "/initier-paiement-en-ligne")
.then()
.statusCode(anyOf(equalTo(500), equalTo(400)));
}
// -------------------------------------------------------------------------
// POST /api/paiements/initier-depot-epargne-en-ligne
// -------------------------------------------------------------------------
@Test
@Order(16)
@TestSecurity(user = "membre@unionflow.com", roles = {"MEMBRE"})
void initierDepotEpargneEnLigne_success_returns201() {
PaiementGatewayResponse response = PaiementGatewayResponse.builder()
.transactionId(UUID.randomUUID())
.redirectUrl("https://wave.example.com/pay/DEPOT-001")
.build();
when(paiementService.initierDepotEpargneEnLigne(any())).thenReturn(response);
String body = String.format("""
{
"compteId": "%s",
"montant": 10000,
"numeroTelephone": "771234567"
}
""", UUID.randomUUID());
given()
.contentType(ContentType.JSON)
.body(body)
.when()
.post(BASE_PATH + "/initier-depot-epargne-en-ligne")
.then()
.statusCode(anyOf(equalTo(201), equalTo(400)));
}
@Test
@Order(17)
@TestSecurity(user = "membre@unionflow.com", roles = {"MEMBRE"})
void initierDepotEpargneEnLigne_serverError_returns500() {
when(paiementService.initierDepotEpargneEnLigne(any()))
.thenThrow(new RuntimeException("epargne error"));
String body = String.format("""
{"compteId": "%s", "montant": 10000, "numeroTelephone": "771234567"}
""", UUID.randomUUID());
given()
.contentType(ContentType.JSON)
.body(body)
.when()
.post(BASE_PATH + "/initier-depot-epargne-en-ligne")
.then()
.statusCode(anyOf(equalTo(500), equalTo(400)));
}
// -------------------------------------------------------------------------
// POST /api/paiements/declarer-paiement-manuel
// -------------------------------------------------------------------------
@Test
@Order(18)
@TestSecurity(user = "membre@unionflow.com", roles = {"MEMBRE"})
void declarerPaiementManuel_success_returns201() {
PaiementResponse response = PaiementResponse.builder()
.numeroReference("MANUEL-001")
.build();
when(paiementService.declarerPaiementManuel(any())).thenReturn(response);
String body = String.format("""
{
"cotisationId": "%s",
"methodePaiement": "ESPECES",
"montant": 5000,
"dateDeclaration": "2026-03-21",
"commentaire": "Paiement remis en main propre"
}
""", UUID.randomUUID());
given()
.contentType(ContentType.JSON)
.body(body)
.when()
.post(BASE_PATH + "/declarer-paiement-manuel")
.then()
.statusCode(201)
.contentType(ContentType.JSON);
}
@Test
@Order(19)
@TestSecurity(user = "membre@unionflow.com", roles = {"MEMBRE"})
void declarerPaiementManuel_serverError_returns500() {
when(paiementService.declarerPaiementManuel(any()))
.thenThrow(new RuntimeException("declaration error"));
String body = String.format("""
{"cotisationId": "%s", "methodePaiement": "ESPECES", "montant": 5000}
""", UUID.randomUUID());
given()
.contentType(ContentType.JSON)
.body(body)
.when()
.post(BASE_PATH + "/declarer-paiement-manuel")
.then()
.statusCode(500);
}
}