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

View File

@@ -1,19 +1,7 @@
package dev.lions.unionflow.server.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
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 jakarta.persistence.*;
import jakarta.validation.constraints.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.UUID;
@@ -24,23 +12,11 @@ import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
/**
* Table de liaison polymorphique entre un paiement
* et son objet cible.
* Table de liaison polymorphique entre un paiement et son objet cible.
*
* <p>
* Remplace les 4 tables dupliquées
* {@code paiements_cotisations},
* {@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).
* <p>Remplace les tables dupliquées {@code paiements_cotisations},
* {@code paiements_adhesions}, etc. par une table unique utilisant
* le pattern {@code (type_objet_cible, objet_cible_id)}.
*
* @author UnionFlow Team
* @version 3.0
@@ -48,16 +24,12 @@ import lombok.NoArgsConstructor;
*/
@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")
@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"
})
@UniqueConstraint(name = "uk_paiement_objet",
columnNames = {"paiement_id", "type_objet_cible", "objet_cible_id"})
})
@Data
@NoArgsConstructor
@@ -66,65 +38,47 @@ import lombok.NoArgsConstructor;
@EqualsAndHashCode(callSuper = true)
public class PaiementObjet extends BaseEntity {
/** Paiement parent. */
@NotNull
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "paiement_id", nullable = false)
private Paiement paiement;
/** Paiement parent. */
@NotNull
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "paiement_id", nullable = false)
private Paiement paiement;
/**
* Type de l'objet cible (code du domaine
* {@code OBJET_PAIEMENT} dans
* {@code types_reference}).
*
* <p>
* Valeurs attendues : {@code COTISATION},
* {@code ADHESION}, {@code EVENEMENT},
* {@code AIDE}.
*/
@NotBlank
@Size(max = 50)
@Column(name = "type_objet_cible", nullable = false, length = 50)
private String typeObjetCible;
/**
* Type de l'objet cible (ex: 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, demande
* d'adhésion, inscription événement, ou demande
* d'aide).
*/
@NotNull
@Column(name = "objet_cible_id", nullable = false)
private UUID objetCibleId;
/** UUID de l'objet cible. */
@NotNull
@Column(name = "objet_cible_id", nullable = false)
private UUID objetCibleId;
/** Montant appliqué à 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;
/** Montant appliqué à 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;
/** Date d'application du paiement. */
@Column(name = "date_application")
private LocalDateTime dateApplication;
/** Date d'application du paiement. */
@Column(name = "date_application")
private LocalDateTime dateApplication;
/** Commentaire sur l'application. */
@Size(max = 500)
@Column(name = "commentaire", length = 500)
private String commentaire;
/** Commentaire sur l'application. */
@Size(max = 500)
@Column(name = "commentaire", length = 500)
private String commentaire;
/**
* Callback JPA avant la persistance.
*
* <p>
* Initialise {@code dateApplication} si non
* renseignée.
*/
@Override
@PrePersist
protected void onCreate() {
super.onCreate();
if (dateApplication == null) {
dateApplication = LocalDateTime.now();
}
@Override
@PrePersist
protected void onCreate() {
super.onCreate();
if (dateApplication == null) {
dateApplication = LocalDateTime.now();
}
}
}

View File

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

View File

@@ -1,6 +1,10 @@
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.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.PaiementSummaryResponse;
import dev.lions.unionflow.server.service.PaiementService;
@@ -16,193 +20,138 @@ import org.eclipse.microprofile.openapi.annotations.tags.Tag;
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
* @version 3.0
* @since 2025-01-29
* @since 2026-04-13
*/
@Path("/api/paiements")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@RolesAllowed({ "ADMIN", "ADMIN_ORGANISATION", "MEMBRE", "USER" })
@Tag(name = "Paiements", description = "Gestion des paiements : création, validation et suivi")
@RolesAllowed({"ADMIN", "ADMIN_ORGANISATION", "MEMBRE", "USER"})
@Tag(name = "Paiements", description = "Paiements de cotisations — Wave Checkout et manuel")
public class PaiementResource {
private static final Logger LOG = Logger.getLogger(PaiementResource.class);
private static final Logger LOG = Logger.getLogger(PaiementResource.class);
@Inject
PaiementService paiementService;
@Inject
PaiementService paiementService;
/**
* 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();
}
// ── Lecture ───────────────────────────────────────────────────────────────
/**
* 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();
}
@GET
@Path("/{id}")
public Response trouverParId(@PathParam("id") UUID id) {
LOG.infof("GET /api/paiements/%s", id);
PaiementResponse result = paiementService.trouverParId(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();
}
@GET
@Path("/reference/{numeroReference}")
public Response trouverParNumeroReference(
@PathParam("numeroReference") String numeroReference) {
LOG.infof("GET /api/paiements/reference/%s", numeroReference);
PaiementResponse result = paiementService.trouverParNumeroReference(numeroReference);
return Response.ok(result).build();
}
/**
* Trouve un paiement par son ID
*
* @param id ID du paiement
* @return Paiement
*/
@GET
@Path("/{id}")
public Response trouverParId(@PathParam("id") UUID id) {
LOG.infof("GET /api/paiements/%s", id);
PaiementResponse result = paiementService.trouverParId(id);
return Response.ok(result).build();
}
@GET
@Path("/membre/{membreId}")
@RolesAllowed({"ADMIN", "ADMIN_ORGANISATION"})
public Response listerParMembre(@PathParam("membreId") UUID membreId) {
LOG.infof("GET /api/paiements/membre/%s", membreId);
List<PaiementSummaryResponse> result = paiementService.listerParMembre(membreId);
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
@Path("/reference/{numeroReference}")
public Response trouverParNumeroReference(@PathParam("numeroReference") String numeroReference) {
LOG.infof("GET /api/paiements/reference/%s", numeroReference);
PaiementResponse result = paiementService.trouverParNumeroReference(numeroReference);
return Response.ok(result).build();
}
@GET
@Path("/mon-historique")
@RolesAllowed({"MEMBRE", "ADMIN", "ADMIN_ORGANISATION"})
public Response getMonHistoriquePaiements(
@QueryParam("limit") @DefaultValue("20") int limit) {
LOG.infof("GET /api/paiements/mon-historique?limit=%d", limit);
List<PaiementSummaryResponse> result = paiementService.getMonHistoriquePaiements(limit);
return Response.ok(result).build();
}
/**
* Liste tous les paiements d'un membre
*
* @param membreId ID du membre
* @return Liste des paiements
*/
@GET
@Path("/membre/{membreId}")
public Response listerParMembre(@PathParam("membreId") UUID membreId) {
LOG.infof("GET /api/paiements/membre/%s", membreId);
List<PaiementSummaryResponse> result = paiementService.listerParMembre(membreId);
return Response.ok(result).build();
}
// ── Administration ────────────────────────────────────────────────────────
/**
* 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
@Path("/mes-paiements/historique")
@RolesAllowed({ "MEMBRE", "ADMIN", "ADMIN_ORGANISATION" })
public Response getMonHistoriquePaiements(
@QueryParam("limit") @DefaultValue("5") int limit) {
LOG.infof("GET /api/paiements/mes-paiements/historique?limit=%d", limit);
List<PaiementSummaryResponse> result = paiementService.getMonHistoriquePaiements(limit);
return Response.ok(result).build();
}
@POST
@RolesAllowed({"ADMIN", "ADMIN_ORGANISATION"})
public Response creerPaiement(@Valid CreatePaiementRequest request) {
LOG.infof("POST /api/paiements — référence: %s", request.numeroReference());
PaiementResponse result = paiementService.creerPaiement(request);
return Response.status(Response.Status.CREATED).entity(result).build();
}
/**
* 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
@Path("/initier-paiement-en-ligne")
@RolesAllowed({ "MEMBRE", "MEMBRE_ACTIF", "ADMIN", "ADMIN_ORGANISATION", "USER" })
public Response initierPaiementEnLigne(@Valid dev.lions.unionflow.server.api.dto.paiement.request.InitierPaiementEnLigneRequest request) {
LOG.infof("POST /api/paiements/initier-paiement-en-ligne - cotisation: %s, méthode: %s",
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();
}
@POST
@Path("/{id}/valider")
@RolesAllowed({"ADMIN", "ADMIN_ORGANISATION"})
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();
}
/**
* 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
@Path("/initier-depot-epargne-en-ligne")
@RolesAllowed({ "MEMBRE", "MEMBRE_ACTIF", "ADMIN", "ADMIN_ORGANISATION", "USER" })
public Response initierDepotEpargneEnLigne(@Valid dev.lions.unionflow.server.api.dto.paiement.request.InitierDepotEpargneRequest request) {
LOG.infof("POST /api/paiements/initier-depot-epargne-en-ligne - compte: %s, montant: %s",
request.compteId(), request.montant());
dev.lions.unionflow.server.api.dto.paiement.response.PaiementGatewayResponse result =
paiementService.initierDepotEpargneEnLigne(request);
return Response.status(Response.Status.CREATED).entity(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();
}
/**
* 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();
}
// ── Flux Wave Checkout (QR code web) ──────────────────────────────────────
/**
* Déclare un paiement manuel (espèces, virement, chèque).
* Le paiement est créé avec le statut EN_ATTENTE_VALIDATION.
* Le trésorier devra le valider via une page admin.
*
* @param request Données du paiement manuel
* @return Paiement créé (statut EN_ATTENTE_VALIDATION)
*/
@POST
@Path("/declarer-paiement-manuel")
@RolesAllowed({ "MEMBRE", "ADMIN", "ADMIN_ORGANISATION" })
public Response declarerPaiementManuel(@Valid dev.lions.unionflow.server.api.dto.paiement.request.DeclarerPaiementManuelRequest request) {
LOG.infof("POST /api/paiements/declarer-paiement-manuel - cotisation: %s, méthode: %s",
request.cotisationId(), request.methodePaiement());
PaiementResponse result = paiementService.declarerPaiementManuel(request);
return Response.status(Response.Status.CREATED).entity(result).build();
}
/**
* Initie un paiement Wave via Checkout QR code.
* Le web encode le {@code waveLaunchUrl} en QR code, l'utilisateur le scanne
* depuis l'app Wave. Après confirmation, Wave redirige vers la success URL.
*/
@POST
@Path("/initier-paiement-en-ligne")
@RolesAllowed({"MEMBRE", "ADMIN", "ADMIN_ORGANISATION", "USER"})
public Response initierPaiementEnLigne(@Valid InitierPaiementEnLigneRequest request) {
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());
PaiementResponse result = paiementService.declarerPaiementManuel(request);
return Response.status(Response.Status.CREATED).entity(result).build();
}
}

View File

@@ -1,6 +1,5 @@
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.response.PaiementResponse;
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.mutuelle.epargne.CompteEpargneRepository;
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.WaveCheckoutSessionResponse;
import jakarta.enterprise.context.ApplicationScoped;
@@ -65,12 +63,6 @@ public class PaiementService {
@Inject
CompteEpargneRepository compteEpargneRepository;
@Inject
MembreOrganisationRepository membreOrganisationRepository;
@Inject
NotificationService notificationService;
@Inject
io.quarkus.security.identity.SecurityIdentity securityIdentity;
@@ -333,11 +325,7 @@ public class PaiementService {
.build();
intentionPaiementRepository.persist(intention);
// Web (sans numéro de téléphone) → page HTML de confirmation ; Mobile → deep link app
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 successUrl = base + "/api/wave-redirect/success?ref=" + intention.getId();
String errorUrl = base + "/api/wave-redirect/error?ref=" + intention.getId();
String clientRef = intention.getId().toString();
// XOF : montant entier, pas de décimales (spec Wave)
@@ -397,113 +385,6 @@ public class PaiementService {
.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). */
private static String toE164(String numeroTelephone) {
if (numeroTelephone == null || numeroTelephone.isBlank()) return null;
@@ -628,21 +509,13 @@ public class PaiementService {
paiementRepository.persist(paiement);
// Notifier l'admin de l'organisation pour validation du paiement manuel
membreOrganisationRepository.findFirstByMembreId(membreConnecte.getId())
.ifPresent(mo -> {
CreateNotificationRequest notif = CreateNotificationRequest.builder()
.typeNotification("VALIDATION_PAIEMENT_REQUIS")
.priorite("HAUTE")
.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());
});
// TODO: Créer une notification pour le trésorier
// notificationService.creerNotification(
// "VALIDATION_PAIEMENT_REQUIS",
// "Validation paiement manuel requis",
// "Le membre " + membreConnecte.getNumeroMembre() + " a déclaré un paiement manuel à valider.",
// tresorierIds
// );
LOG.infof("Paiement manuel déclaré avec succès: ID=%s, Référence=%s (EN_ATTENTE_VALIDATION)",
paiement.getId(), paiement.getNumeroReference());
@@ -650,6 +523,69 @@ public class PaiementService {
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
// ========================================
@@ -672,6 +608,10 @@ public class PaiementService {
/** Convertit une entité en Response DTO */
private PaiementResponse convertToResponse(Paiement paiement) {
if (paiement == null) {
return null;
}
PaiementResponse response = new PaiementResponse();
response.setId(paiement.getId());
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);
}
}