feat(versement): nouveau module Versement (paiements rattachés à des objets)
- Entités : Versement, VersementObjet (lien polymorphique vers cotisation/adhesion/etc.) - VersementRepository : requêtes par membre, org, période - VersementResource : endpoints REST /api/versements - VersementService : logique métier (validation, rattachement objets) - Migration V27 : ajout numeroTelephone sur versements
This commit is contained in:
171
src/main/java/dev/lions/unionflow/server/entity/Versement.java
Normal file
171
src/main/java/dev/lions/unionflow/server/entity/Versement.java
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
package dev.lions.unionflow.server.entity;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import jakarta.validation.constraints.*;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
|
import lombok.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Versement — acte de régler une cotisation ou de déposer des fonds.
|
||||||
|
*
|
||||||
|
* <p>Un versement peut être effectué :
|
||||||
|
* <ul>
|
||||||
|
* <li>Via <b>Wave Mobile Money</b> : deep link natif, app Wave sur le même téléphone</li>
|
||||||
|
* <li>Manuellement : espèces, virement, chèque → statut EN_ATTENTE_VALIDATION</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>Table DB : {@code paiements} (nom hérité, conservé pour compatibilité Flyway).
|
||||||
|
*
|
||||||
|
* @author UnionFlow Team
|
||||||
|
* @version 4.0
|
||||||
|
* @since 2026-04-13
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "paiements", indexes = {
|
||||||
|
@Index(name = "idx_paiement_numero_reference", columnList = "numero_reference", unique = true),
|
||||||
|
@Index(name = "idx_paiement_membre", columnList = "membre_id"),
|
||||||
|
@Index(name = "idx_paiement_statut", columnList = "statut_paiement"),
|
||||||
|
@Index(name = "idx_paiement_methode", columnList = "methode_paiement"),
|
||||||
|
@Index(name = "idx_paiement_date", columnList = "date_paiement")
|
||||||
|
})
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Builder
|
||||||
|
@EqualsAndHashCode(callSuper = true)
|
||||||
|
public class Versement extends BaseEntity {
|
||||||
|
|
||||||
|
private static final AtomicLong REFERENCE_COUNTER =
|
||||||
|
new AtomicLong(System.currentTimeMillis() % 1_000_000_000_000L);
|
||||||
|
|
||||||
|
// ── Identité ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Référence unique (ex: VRS-2026-XXXXXXXXXXXX) */
|
||||||
|
@NotBlank
|
||||||
|
@Column(name = "numero_reference", unique = true, nullable = false, length = 50)
|
||||||
|
private String numeroReference;
|
||||||
|
|
||||||
|
// ── Montant ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@NotNull
|
||||||
|
@DecimalMin(value = "0.0", message = "Le montant doit être positif")
|
||||||
|
@Digits(integer = 12, fraction = 2)
|
||||||
|
@Column(name = "montant", nullable = false, precision = 14, scale = 2)
|
||||||
|
private BigDecimal montant;
|
||||||
|
|
||||||
|
@NotBlank
|
||||||
|
@Pattern(regexp = "^[A-Z]{3}$", message = "Code devise ISO à 3 lettres requis")
|
||||||
|
@Column(name = "code_devise", nullable = false, length = 3)
|
||||||
|
private String codeDevise;
|
||||||
|
|
||||||
|
// ── Méthode & Statut ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** WAVE | ESPECES | VIREMENT | CHEQUE | AUTRE */
|
||||||
|
@NotNull
|
||||||
|
@Column(name = "methode_paiement", nullable = false, length = 50)
|
||||||
|
private String methodePaiement;
|
||||||
|
|
||||||
|
/** EN_ATTENTE | EN_COURS | CONFIRME | ECHEC | EN_ATTENTE_VALIDATION | ANNULE */
|
||||||
|
@NotNull
|
||||||
|
@Builder.Default
|
||||||
|
@Column(name = "statut_paiement", nullable = false, length = 30)
|
||||||
|
private String statutPaiement = "EN_ATTENTE";
|
||||||
|
|
||||||
|
// ── Dates ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Column(name = "date_paiement")
|
||||||
|
private LocalDateTime datePaiement;
|
||||||
|
|
||||||
|
@Column(name = "date_validation")
|
||||||
|
private LocalDateTime dateValidation;
|
||||||
|
|
||||||
|
// ── Validation ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Column(name = "validateur", length = 255)
|
||||||
|
private String validateur;
|
||||||
|
|
||||||
|
// ── Traçabilité ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** ID transaction Wave (TCN...) ou référence chèque / bordereau */
|
||||||
|
@Column(name = "reference_externe", length = 500)
|
||||||
|
private String referenceExterne;
|
||||||
|
|
||||||
|
@Column(name = "url_preuve", length = 1000)
|
||||||
|
private String urlPreuve;
|
||||||
|
|
||||||
|
@Column(name = "commentaire", length = 1000)
|
||||||
|
private String commentaire;
|
||||||
|
|
||||||
|
@Column(name = "ip_address", length = 45)
|
||||||
|
private String ipAddress;
|
||||||
|
|
||||||
|
@Column(name = "user_agent", length = 500)
|
||||||
|
private String userAgent;
|
||||||
|
|
||||||
|
// ── Téléphone Wave ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Numéro de téléphone Wave utilisé pour ce versement.
|
||||||
|
* Pré-rempli depuis le profil du membre (même téléphone qu'UnionFlow),
|
||||||
|
* modifiable à l'étape "Récapitulatif" avant de tapper "Payer".
|
||||||
|
*/
|
||||||
|
@Column(name = "numero_telephone", length = 20)
|
||||||
|
private String numeroTelephone;
|
||||||
|
|
||||||
|
// ── Relations ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@NotNull
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "membre_id", nullable = false)
|
||||||
|
private Membre membre;
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
@OneToMany(mappedBy = "versement", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
|
||||||
|
@Builder.Default
|
||||||
|
private List<VersementObjet> versementsObjets = new ArrayList<>();
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "transaction_wave_id")
|
||||||
|
private TransactionWave transactionWave;
|
||||||
|
|
||||||
|
// ── Méthodes métier ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Génère une référence unique : VRS-YYYY-XXXXXXXXXXXX */
|
||||||
|
public static String genererNumeroReference() {
|
||||||
|
return "VRS-"
|
||||||
|
+ LocalDateTime.now().getYear()
|
||||||
|
+ "-"
|
||||||
|
+ String.format("%012d", REFERENCE_COUNTER.incrementAndGet() % 1_000_000_000_000L);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vrai si le versement est confirmé (Wave) ou validé (manuel) */
|
||||||
|
public boolean isConfirme() {
|
||||||
|
return "CONFIRME".equals(statutPaiement) || "VALIDE".equals(statutPaiement);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vrai si le versement peut encore être modifié ou annulé */
|
||||||
|
public boolean peutEtreModifie() {
|
||||||
|
return !"CONFIRME".equals(statutPaiement)
|
||||||
|
&& !"VALIDE".equals(statutPaiement)
|
||||||
|
&& !"ANNULE".equals(statutPaiement);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PrePersist
|
||||||
|
protected void onCreate() {
|
||||||
|
super.onCreate();
|
||||||
|
if (numeroReference == null || numeroReference.isBlank()) {
|
||||||
|
numeroReference = genererNumeroReference();
|
||||||
|
}
|
||||||
|
if (statutPaiement == null) {
|
||||||
|
statutPaiement = "EN_ATTENTE";
|
||||||
|
}
|
||||||
|
if (datePaiement == null) {
|
||||||
|
datePaiement = LocalDateTime.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package dev.lions.unionflow.server.entity;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import jakarta.validation.constraints.*;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.UUID;
|
||||||
|
import lombok.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liaison polymorphique entre un versement et son objet cible.
|
||||||
|
*
|
||||||
|
* <p>Remplace les 4 tables dupliquées (paiements_cotisations, paiements_adhesions,
|
||||||
|
* paiements_evenements, paiements_aides) par une table unique utilisant le pattern
|
||||||
|
* {@code (typeObjetCible, objetCibleId)}.
|
||||||
|
*
|
||||||
|
* <p>Table DB : {@code paiements_objets} (nom hérité, conservé pour compatibilité Flyway).
|
||||||
|
*
|
||||||
|
* @author UnionFlow Team
|
||||||
|
* @version 4.0
|
||||||
|
* @since 2026-04-13
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "paiements_objets", indexes = {
|
||||||
|
@Index(name = "idx_po_paiement", columnList = "paiement_id"),
|
||||||
|
@Index(name = "idx_po_objet", columnList = "type_objet_cible, objet_cible_id"),
|
||||||
|
@Index(name = "idx_po_type", columnList = "type_objet_cible")
|
||||||
|
}, uniqueConstraints = {
|
||||||
|
@UniqueConstraint(name = "uk_paiement_objet", columnNames = {
|
||||||
|
"paiement_id", "type_objet_cible", "objet_cible_id"
|
||||||
|
})
|
||||||
|
})
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Builder
|
||||||
|
@EqualsAndHashCode(callSuper = true)
|
||||||
|
public class VersementObjet extends BaseEntity {
|
||||||
|
|
||||||
|
/** Versement parent. */
|
||||||
|
@NotNull
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "paiement_id", nullable = false)
|
||||||
|
private Versement versement;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type de l'objet cible (domaine {@code OBJET_PAIEMENT}).
|
||||||
|
* Valeurs : COTISATION | ADHESION | EVENEMENT | AIDE.
|
||||||
|
*/
|
||||||
|
@NotBlank
|
||||||
|
@Size(max = 50)
|
||||||
|
@Column(name = "type_objet_cible", nullable = false, length = 50)
|
||||||
|
private String typeObjetCible;
|
||||||
|
|
||||||
|
/** UUID de l'objet cible (cotisation, adhésion, inscription, aide). */
|
||||||
|
@NotNull
|
||||||
|
@Column(name = "objet_cible_id", nullable = false)
|
||||||
|
private UUID objetCibleId;
|
||||||
|
|
||||||
|
/** Montant affecté à cet objet cible. */
|
||||||
|
@NotNull
|
||||||
|
@DecimalMin(value = "0.0", message = "Le montant doit être positif")
|
||||||
|
@Digits(integer = 12, fraction = 2)
|
||||||
|
@Column(name = "montant_applique", nullable = false, precision = 14, scale = 2)
|
||||||
|
private BigDecimal montantApplique;
|
||||||
|
|
||||||
|
@Column(name = "date_application")
|
||||||
|
private LocalDateTime dateApplication;
|
||||||
|
|
||||||
|
@Size(max = 500)
|
||||||
|
@Column(name = "commentaire", length = 500)
|
||||||
|
private String commentaire;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@PrePersist
|
||||||
|
protected void onCreate() {
|
||||||
|
super.onCreate();
|
||||||
|
if (dateApplication == null) {
|
||||||
|
dateApplication = LocalDateTime.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package dev.lions.unionflow.server.repository;
|
||||||
|
|
||||||
|
import dev.lions.unionflow.server.api.enums.paiement.MethodePaiement;
|
||||||
|
import dev.lions.unionflow.server.api.enums.paiement.StatutPaiement;
|
||||||
|
import dev.lions.unionflow.server.entity.Versement;
|
||||||
|
import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase;
|
||||||
|
import io.quarkus.panache.common.Sort;
|
||||||
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repository pour l'entité {@link Versement}.
|
||||||
|
*
|
||||||
|
* @author UnionFlow Team
|
||||||
|
* @version 4.0
|
||||||
|
* @since 2026-04-13
|
||||||
|
*/
|
||||||
|
@ApplicationScoped
|
||||||
|
public class VersementRepository implements PanacheRepositoryBase<Versement, UUID> {
|
||||||
|
|
||||||
|
/** Trouve un versement actif par UUID. */
|
||||||
|
public Optional<Versement> findVersementById(UUID id) {
|
||||||
|
return find("id = ?1 AND actif = true", id).firstResultOptional();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Trouve un versement par son numéro de référence. */
|
||||||
|
public Optional<Versement> findByNumeroReference(String numeroReference) {
|
||||||
|
return find("numeroReference", numeroReference).firstResultOptional();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Liste tous les versements actifs d'un membre, les plus récents d'abord. */
|
||||||
|
public List<Versement> findByMembreId(UUID membreId) {
|
||||||
|
return find(
|
||||||
|
"membre.id = ?1 AND actif = true",
|
||||||
|
Sort.by("datePaiement", Sort.Direction.Descending),
|
||||||
|
membreId
|
||||||
|
).list();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Liste les versements par statut. */
|
||||||
|
public List<Versement> findByStatut(StatutPaiement statut) {
|
||||||
|
return find(
|
||||||
|
"statutPaiement = ?1 AND actif = true",
|
||||||
|
Sort.by("datePaiement", Sort.Direction.Descending),
|
||||||
|
statut.name()
|
||||||
|
).list();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Liste les versements par méthode. */
|
||||||
|
public List<Versement> findByMethode(MethodePaiement methode) {
|
||||||
|
return find(
|
||||||
|
"methodePaiement = ?1 AND actif = true",
|
||||||
|
Sort.by("datePaiement", Sort.Direction.Descending),
|
||||||
|
methode.name()
|
||||||
|
).list();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Liste les versements confirmés dans une période donnée. */
|
||||||
|
public List<Versement> findConfirmesParPeriode(LocalDateTime dateDebut, LocalDateTime dateFin) {
|
||||||
|
return find(
|
||||||
|
"statutPaiement IN ('CONFIRME', 'VALIDE') "
|
||||||
|
+ "AND dateValidation >= ?1 AND dateValidation <= ?2 AND actif = true",
|
||||||
|
Sort.by("dateValidation", Sort.Direction.Descending),
|
||||||
|
dateDebut,
|
||||||
|
dateFin
|
||||||
|
).list();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Calcule le montant total des versements confirmés dans une période. */
|
||||||
|
public BigDecimal calculerMontantTotalConfirmes(LocalDateTime dateDebut, LocalDateTime dateFin) {
|
||||||
|
return findConfirmesParPeriode(dateDebut, dateFin).stream()
|
||||||
|
.map(Versement::getMontant)
|
||||||
|
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
package dev.lions.unionflow.server.resource;
|
||||||
|
|
||||||
|
import dev.lions.unionflow.server.api.dto.versement.request.DeclarerVersementManuelRequest;
|
||||||
|
import dev.lions.unionflow.server.api.dto.versement.request.InitierDepotEpargneRequest;
|
||||||
|
import dev.lions.unionflow.server.api.dto.versement.request.InitierVersementWaveRequest;
|
||||||
|
import dev.lions.unionflow.server.api.dto.versement.response.VersementGatewayResponse;
|
||||||
|
import dev.lions.unionflow.server.api.dto.versement.response.VersementResponse;
|
||||||
|
import dev.lions.unionflow.server.api.dto.versement.response.VersementStatutResponse;
|
||||||
|
import dev.lions.unionflow.server.api.dto.versement.response.VersementSummaryResponse;
|
||||||
|
import dev.lions.unionflow.server.service.VersementService;
|
||||||
|
import jakarta.annotation.security.RolesAllowed;
|
||||||
|
import jakarta.inject.Inject;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import jakarta.ws.rs.*;
|
||||||
|
import jakarta.ws.rs.core.MediaType;
|
||||||
|
import jakarta.ws.rs.core.Response;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.eclipse.microprofile.openapi.annotations.tags.Tag;
|
||||||
|
import org.jboss.logging.Logger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resource REST pour la gestion des versements.
|
||||||
|
*
|
||||||
|
* <p>Endpoints principaux :
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code POST /api/versements/initier-wave} — démarre le flux Wave deep link</li>
|
||||||
|
* <li>{@code GET /api/versements/statut/{intentionId}} — retour deep link / polling</li>
|
||||||
|
* <li>{@code POST /api/versements/declarer-manuel} — déclaration espèces/virement/chèque</li>
|
||||||
|
* <li>{@code GET /api/versements/mes-versements} — historique du membre connecté</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* @author UnionFlow Team
|
||||||
|
* @version 4.0
|
||||||
|
* @since 2026-04-13
|
||||||
|
*/
|
||||||
|
@Path("/api/versements")
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
|
@RolesAllowed({"ADMIN", "ADMIN_ORGANISATION", "MEMBRE", "USER"})
|
||||||
|
@Tag(name = "Versements", description = "Versements de cotisations — Wave et manuel")
|
||||||
|
public class VersementResource {
|
||||||
|
|
||||||
|
private static final Logger LOG = Logger.getLogger(VersementResource.class);
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
VersementService versementService;
|
||||||
|
|
||||||
|
// ── Lecture ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("/{id}")
|
||||||
|
public Response trouverParId(@PathParam("id") UUID id) {
|
||||||
|
LOG.infof("GET /api/versements/%s", id);
|
||||||
|
VersementResponse result = versementService.trouverParId(id);
|
||||||
|
return Response.ok(result).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("/reference/{numeroReference}")
|
||||||
|
public Response trouverParNumeroReference(
|
||||||
|
@PathParam("numeroReference") String numeroReference) {
|
||||||
|
LOG.infof("GET /api/versements/reference/%s", numeroReference);
|
||||||
|
VersementResponse result = versementService.trouverParNumeroReference(numeroReference);
|
||||||
|
return Response.ok(result).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("/membre/{membreId}")
|
||||||
|
public Response listerParMembre(@PathParam("membreId") UUID membreId) {
|
||||||
|
LOG.infof("GET /api/versements/membre/%s", membreId);
|
||||||
|
List<VersementSummaryResponse> result = versementService.listerParMembre(membreId);
|
||||||
|
return Response.ok(result).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("/mes-versements")
|
||||||
|
@RolesAllowed({"MEMBRE", "ADMIN", "ADMIN_ORGANISATION"})
|
||||||
|
public Response getMesVersements(
|
||||||
|
@QueryParam("limit") @DefaultValue("20") int limit) {
|
||||||
|
LOG.infof("GET /api/versements/mes-versements?limit=%d", limit);
|
||||||
|
List<VersementSummaryResponse> result = versementService.getMesVersements(limit);
|
||||||
|
return Response.ok(result).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Validation / Annulation ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("/{id}/valider")
|
||||||
|
@RolesAllowed({"ADMIN", "ADMIN_ORGANISATION"})
|
||||||
|
public Response validerVersement(@PathParam("id") UUID id) {
|
||||||
|
LOG.infof("POST /api/versements/%s/valider", id);
|
||||||
|
VersementResponse result = versementService.validerVersement(id);
|
||||||
|
return Response.ok(result).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("/{id}/annuler")
|
||||||
|
@RolesAllowed({"ADMIN", "ADMIN_ORGANISATION", "MEMBRE"})
|
||||||
|
public Response annulerVersement(@PathParam("id") UUID id) {
|
||||||
|
LOG.infof("POST /api/versements/%s/annuler", id);
|
||||||
|
VersementResponse result = versementService.annulerVersement(id);
|
||||||
|
return Response.ok(result).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Flux Wave ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initie un versement Wave.
|
||||||
|
*
|
||||||
|
* <p>Le mobile appelle cet endpoint puis ouvre le {@code waveLaunchUrl} retourné
|
||||||
|
* avec {@code url_launcher}. Wave s'ouvre avec le montant et le numéro pré-remplis.
|
||||||
|
* Après confirmation, Wave redirige vers
|
||||||
|
* {@code unionflow://payment?result=success&ref={clientReference}}.
|
||||||
|
*/
|
||||||
|
@POST
|
||||||
|
@Path("/initier-wave")
|
||||||
|
@RolesAllowed({"MEMBRE", "MEMBRE_ACTIF", "ADMIN", "ADMIN_ORGANISATION", "USER"})
|
||||||
|
public Response initierVersementWave(@Valid InitierVersementWaveRequest request) {
|
||||||
|
LOG.infof("POST /api/versements/initier-wave — cotisation: %s", request.cotisationId());
|
||||||
|
VersementGatewayResponse result = versementService.initierVersementWave(request);
|
||||||
|
return Response.status(Response.Status.CREATED).entity(result).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retour deep link / polling du statut d'un versement Wave.
|
||||||
|
*
|
||||||
|
* <p>Appelé par le mobile au retour du deep link
|
||||||
|
* {@code unionflow://payment?result=success&ref={intentionId}}
|
||||||
|
* pour confirmer que le paiement est bien enregistré côté UnionFlow.
|
||||||
|
* Également utilisé par le web en polling toutes les 3 secondes.
|
||||||
|
*/
|
||||||
|
@GET
|
||||||
|
@Path("/statut/{intentionId}")
|
||||||
|
@RolesAllowed({"MEMBRE", "MEMBRE_ACTIF", "ADMIN", "ADMIN_ORGANISATION", "USER"})
|
||||||
|
public Response getStatutVersement(@PathParam("intentionId") UUID intentionId) {
|
||||||
|
LOG.infof("GET /api/versements/statut/%s", intentionId);
|
||||||
|
VersementStatutResponse result = versementService.verifierStatutVersement(intentionId);
|
||||||
|
return Response.ok(result).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Flux manuel ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Déclare un versement manuel (espèces, virement, chèque).
|
||||||
|
* Le versement est créé avec le statut EN_ATTENTE_VALIDATION.
|
||||||
|
* Le trésorier devra le valider via la page admin.
|
||||||
|
*/
|
||||||
|
@POST
|
||||||
|
@Path("/declarer-manuel")
|
||||||
|
@RolesAllowed({"MEMBRE", "ADMIN", "ADMIN_ORGANISATION"})
|
||||||
|
public Response declarerVersementManuel(@Valid DeclarerVersementManuelRequest request) {
|
||||||
|
LOG.infof("POST /api/versements/declarer-manuel — cotisation: %s, méthode: %s",
|
||||||
|
request.cotisationId(), request.methodePaiement());
|
||||||
|
VersementResponse result = versementService.declarerVersementManuel(request);
|
||||||
|
return Response.status(Response.Status.CREATED).entity(result).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Dépôt épargne ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("/initier-depot-epargne")
|
||||||
|
@RolesAllowed({"MEMBRE", "MEMBRE_ACTIF", "ADMIN", "ADMIN_ORGANISATION", "USER"})
|
||||||
|
public Response initierDepotEpargne(@Valid InitierDepotEpargneRequest request) {
|
||||||
|
LOG.infof("POST /api/versements/initier-depot-epargne — compte: %s, montant: %s",
|
||||||
|
request.compteId(), request.montant());
|
||||||
|
VersementGatewayResponse result = versementService.initierDepotEpargneEnLigne(request);
|
||||||
|
return Response.status(Response.Status.CREATED).entity(result).build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,558 @@
|
|||||||
|
package dev.lions.unionflow.server.service;
|
||||||
|
|
||||||
|
import dev.lions.unionflow.server.api.dto.notification.request.CreateNotificationRequest;
|
||||||
|
import dev.lions.unionflow.server.api.dto.versement.request.DeclarerVersementManuelRequest;
|
||||||
|
import dev.lions.unionflow.server.api.dto.versement.request.InitierDepotEpargneRequest;
|
||||||
|
import dev.lions.unionflow.server.api.dto.versement.request.InitierVersementWaveRequest;
|
||||||
|
import dev.lions.unionflow.server.api.dto.versement.response.VersementGatewayResponse;
|
||||||
|
import dev.lions.unionflow.server.api.dto.versement.response.VersementResponse;
|
||||||
|
import dev.lions.unionflow.server.api.dto.versement.response.VersementStatutResponse;
|
||||||
|
import dev.lions.unionflow.server.api.dto.versement.response.VersementSummaryResponse;
|
||||||
|
import dev.lions.unionflow.server.api.enums.paiement.StatutIntentionPaiement;
|
||||||
|
import dev.lions.unionflow.server.api.enums.paiement.TypeObjetIntentionPaiement;
|
||||||
|
import dev.lions.unionflow.server.entity.Cotisation;
|
||||||
|
import dev.lions.unionflow.server.entity.IntentionPaiement;
|
||||||
|
import dev.lions.unionflow.server.entity.Membre;
|
||||||
|
import dev.lions.unionflow.server.entity.Versement;
|
||||||
|
import dev.lions.unionflow.server.entity.mutuelle.epargne.CompteEpargne;
|
||||||
|
import dev.lions.unionflow.server.repository.IntentionPaiementRepository;
|
||||||
|
import dev.lions.unionflow.server.repository.MembreOrganisationRepository;
|
||||||
|
import dev.lions.unionflow.server.repository.MembreRepository;
|
||||||
|
import dev.lions.unionflow.server.repository.TypeReferenceRepository;
|
||||||
|
import dev.lions.unionflow.server.repository.VersementRepository;
|
||||||
|
import dev.lions.unionflow.server.repository.mutuelle.epargne.CompteEpargneRepository;
|
||||||
|
import dev.lions.unionflow.server.service.WaveCheckoutService.WaveCheckoutException;
|
||||||
|
import dev.lions.unionflow.server.service.WaveCheckoutService.WaveCheckoutSessionResponse;
|
||||||
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
|
import jakarta.inject.Inject;
|
||||||
|
import jakarta.transaction.Transactional;
|
||||||
|
import jakarta.ws.rs.NotFoundException;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
import org.jboss.logging.Logger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service métier pour la gestion des versements.
|
||||||
|
*
|
||||||
|
* <p>Un versement est l'acte de régler une cotisation. Deux flux sont supportés :
|
||||||
|
* <ol>
|
||||||
|
* <li><b>Wave</b> : deep link natif, app Wave sur le même téléphone, retour via
|
||||||
|
* {@code unionflow://payment?result=success&ref={intentionId}}</li>
|
||||||
|
* <li><b>Manuel</b> : déclaration espèces/virement/chèque → validation trésorier</li>
|
||||||
|
* </ol>
|
||||||
|
*
|
||||||
|
* @author UnionFlow Team
|
||||||
|
* @version 4.0
|
||||||
|
* @since 2026-04-13
|
||||||
|
*/
|
||||||
|
@ApplicationScoped
|
||||||
|
public class VersementService {
|
||||||
|
|
||||||
|
private static final Logger LOG = Logger.getLogger(VersementService.class);
|
||||||
|
|
||||||
|
@Inject VersementRepository versementRepository;
|
||||||
|
@Inject MembreRepository membreRepository;
|
||||||
|
@Inject KeycloakService keycloakService;
|
||||||
|
@Inject TypeReferenceRepository typeReferenceRepository;
|
||||||
|
@Inject IntentionPaiementRepository intentionPaiementRepository;
|
||||||
|
@Inject WaveCheckoutService waveCheckoutService;
|
||||||
|
@Inject CompteEpargneRepository compteEpargneRepository;
|
||||||
|
@Inject MembreOrganisationRepository membreOrganisationRepository;
|
||||||
|
@Inject NotificationService notificationService;
|
||||||
|
@Inject io.quarkus.security.identity.SecurityIdentity securityIdentity;
|
||||||
|
|
||||||
|
// ── Lecture ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public VersementResponse trouverParId(UUID id) {
|
||||||
|
return versementRepository.findVersementById(id)
|
||||||
|
.map(this::convertToResponse)
|
||||||
|
.orElseThrow(() -> new NotFoundException("Versement non trouvé : " + id));
|
||||||
|
}
|
||||||
|
|
||||||
|
public VersementResponse trouverParNumeroReference(String numeroReference) {
|
||||||
|
return versementRepository.findByNumeroReference(numeroReference)
|
||||||
|
.map(this::convertToResponse)
|
||||||
|
.orElseThrow(() -> new NotFoundException("Versement non trouvé : " + numeroReference));
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<VersementSummaryResponse> listerParMembre(UUID membreId) {
|
||||||
|
return versementRepository.findByMembreId(membreId).stream()
|
||||||
|
.map(this::convertToSummaryResponse)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
public BigDecimal calculerMontantTotalConfirmes(LocalDateTime dateDebut, LocalDateTime dateFin) {
|
||||||
|
return versementRepository.calculerMontantTotalConfirmes(dateDebut, dateFin);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Historique des versements du membre connecté (auto-détection).
|
||||||
|
* Retourne uniquement les versements confirmés/validés.
|
||||||
|
*/
|
||||||
|
public List<VersementSummaryResponse> getMesVersements(int limit) {
|
||||||
|
Membre membreConnecte = getMembreConnecte();
|
||||||
|
LOG.infof("Historique versements pour %s, limit=%d",
|
||||||
|
membreConnecte.getNumeroMembre(), limit);
|
||||||
|
|
||||||
|
List<Versement> versements = versementRepository.getEntityManager()
|
||||||
|
.createQuery(
|
||||||
|
"SELECT v FROM Versement v "
|
||||||
|
+ "WHERE v.membre.id = :membreId "
|
||||||
|
+ "AND v.statutPaiement IN ('CONFIRME', 'VALIDE') "
|
||||||
|
+ "ORDER BY v.datePaiement DESC",
|
||||||
|
Versement.class)
|
||||||
|
.setParameter("membreId", membreConnecte.getId())
|
||||||
|
.setMaxResults(limit)
|
||||||
|
.getResultList();
|
||||||
|
|
||||||
|
return versements.stream()
|
||||||
|
.map(this::convertToSummaryResponse)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Validation / Annulation ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public VersementResponse validerVersement(UUID id) {
|
||||||
|
Versement versement = versementRepository.findVersementById(id)
|
||||||
|
.orElseThrow(() -> new NotFoundException("Versement non trouvé : " + id));
|
||||||
|
|
||||||
|
if (versement.isConfirme()) {
|
||||||
|
return convertToResponse(versement);
|
||||||
|
}
|
||||||
|
|
||||||
|
versement.setStatutPaiement("CONFIRME");
|
||||||
|
versement.setDateValidation(LocalDateTime.now());
|
||||||
|
versement.setValidateur(keycloakService.getCurrentUserEmail());
|
||||||
|
versement.setModifiePar(keycloakService.getCurrentUserEmail());
|
||||||
|
versementRepository.persist(versement);
|
||||||
|
|
||||||
|
LOG.infof("Versement validé : %s", id);
|
||||||
|
return convertToResponse(versement);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public VersementResponse annulerVersement(UUID id) {
|
||||||
|
Versement versement = versementRepository.findVersementById(id)
|
||||||
|
.orElseThrow(() -> new NotFoundException("Versement non trouvé : " + id));
|
||||||
|
|
||||||
|
if (!versement.peutEtreModifie()) {
|
||||||
|
throw new IllegalStateException("Le versement ne peut plus être annulé (statut finalisé)");
|
||||||
|
}
|
||||||
|
|
||||||
|
versement.setStatutPaiement("ANNULE");
|
||||||
|
versement.setModifiePar(keycloakService.getCurrentUserEmail());
|
||||||
|
versementRepository.persist(versement);
|
||||||
|
|
||||||
|
LOG.infof("Versement annulé : %s", id);
|
||||||
|
return convertToResponse(versement);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Flux Wave ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initie un versement Wave.
|
||||||
|
*
|
||||||
|
* <ol>
|
||||||
|
* <li>Crée une {@link IntentionPaiement} (hub Wave interne)</li>
|
||||||
|
* <li>Appelle l'API Wave Checkout → obtient {@code waveLaunchUrl}</li>
|
||||||
|
* <li>Retourne {@link VersementGatewayResponse} avec {@code waveLaunchUrl}
|
||||||
|
* pour que {@code url_launcher} ouvre Wave directement</li>
|
||||||
|
* </ol>
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public VersementGatewayResponse initierVersementWave(InitierVersementWaveRequest request) {
|
||||||
|
Membre membreConnecte = getMembreConnecte();
|
||||||
|
LOG.infof("Initiation versement Wave — membre %s, cotisation %s",
|
||||||
|
membreConnecte.getNumeroMembre(), request.cotisationId());
|
||||||
|
|
||||||
|
Cotisation cotisation = versementRepository.getEntityManager()
|
||||||
|
.find(Cotisation.class, request.cotisationId());
|
||||||
|
if (cotisation == null) {
|
||||||
|
throw new NotFoundException("Cotisation non trouvée : " + request.cotisationId());
|
||||||
|
}
|
||||||
|
if (!cotisation.getMembre().getId().equals(membreConnecte.getId())) {
|
||||||
|
throw new IllegalArgumentException("Cette cotisation n'appartient pas au membre connecté");
|
||||||
|
}
|
||||||
|
|
||||||
|
String base = waveCheckoutService.getRedirectBaseUrl().replaceAll("/+$", "");
|
||||||
|
|
||||||
|
// 1. Créer l'intention (détail interne Wave — non exposé dans l'API publique)
|
||||||
|
IntentionPaiement intention = IntentionPaiement.builder()
|
||||||
|
.utilisateur(membreConnecte)
|
||||||
|
.organisation(cotisation.getOrganisation())
|
||||||
|
.montantTotal(cotisation.getMontantDu())
|
||||||
|
.codeDevise(cotisation.getCodeDevise() != null ? cotisation.getCodeDevise() : "XOF")
|
||||||
|
.typeObjet(TypeObjetIntentionPaiement.COTISATION)
|
||||||
|
.statut(StatutIntentionPaiement.INITIEE)
|
||||||
|
.objetsCibles("[{\"type\":\"COTISATION\",\"id\":\"" + cotisation.getId()
|
||||||
|
+ "\",\"montant\":" + cotisation.getMontantDu() + "}]")
|
||||||
|
.build();
|
||||||
|
intentionPaiementRepository.persist(intention);
|
||||||
|
|
||||||
|
// 2. URL success → deep link si mobile, page HTML si web
|
||||||
|
boolean isMobile = request.numeroTelephone() != null && !request.numeroTelephone().isBlank();
|
||||||
|
String successUrl = base + (isMobile
|
||||||
|
? "/api/wave-redirect/success?ref=" + intention.getId()
|
||||||
|
: "/api/wave-redirect/web-success?ref=" + intention.getId());
|
||||||
|
String errorUrl = base + "/api/wave-redirect/error?ref=" + intention.getId();
|
||||||
|
String amountStr = cotisation.getMontantDu()
|
||||||
|
.setScale(0, java.math.RoundingMode.HALF_UP).toString();
|
||||||
|
String restrictMobile = toE164(request.numeroTelephone());
|
||||||
|
|
||||||
|
// 3. Appel Wave Checkout API
|
||||||
|
WaveCheckoutSessionResponse session;
|
||||||
|
try {
|
||||||
|
session = waveCheckoutService.createSession(
|
||||||
|
amountStr, "XOF", successUrl, errorUrl,
|
||||||
|
intention.getId().toString(), restrictMobile);
|
||||||
|
} catch (WaveCheckoutException e) {
|
||||||
|
LOG.errorf(e, "Wave Checkout API error : %s", e.getMessage());
|
||||||
|
intention.setStatut(StatutIntentionPaiement.ECHOUEE);
|
||||||
|
intentionPaiementRepository.persist(intention);
|
||||||
|
throw new jakarta.ws.rs.BadRequestException("Wave : " + e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
intention.setWaveCheckoutSessionId(session.id);
|
||||||
|
intention.setWaveLaunchUrl(session.waveLaunchUrl);
|
||||||
|
intention.setStatut(StatutIntentionPaiement.EN_COURS);
|
||||||
|
intentionPaiementRepository.persist(intention);
|
||||||
|
|
||||||
|
cotisation.setIntentionPaiement(intention);
|
||||||
|
versementRepository.getEntityManager().merge(cotisation);
|
||||||
|
|
||||||
|
// 4. Créer le versement en EN_ATTENTE
|
||||||
|
Versement versement = new Versement();
|
||||||
|
versement.setNumeroReference("VRS-WAVE-" + intention.getId().toString().substring(0, 8).toUpperCase());
|
||||||
|
versement.setMontant(cotisation.getMontantDu());
|
||||||
|
versement.setCodeDevise("XOF");
|
||||||
|
versement.setMethodePaiement("WAVE");
|
||||||
|
versement.setStatutPaiement("EN_ATTENTE");
|
||||||
|
versement.setMembre(membreConnecte);
|
||||||
|
versement.setReferenceExterne(session.id);
|
||||||
|
versement.setNumeroTelephone(request.numeroTelephone());
|
||||||
|
versement.setCommentaire("Versement Wave — session " + session.id);
|
||||||
|
versement.setCreePar(membreConnecte.getEmail());
|
||||||
|
versementRepository.persist(versement);
|
||||||
|
|
||||||
|
LOG.infof("Versement Wave initié : intention=%s, session=%s, waveLaunchUrl=%s",
|
||||||
|
intention.getId(), session.id, session.waveLaunchUrl);
|
||||||
|
|
||||||
|
return VersementGatewayResponse.builder()
|
||||||
|
.versementId(versement.getId())
|
||||||
|
.waveLaunchUrl(session.waveLaunchUrl)
|
||||||
|
.waveCheckoutSessionId(session.id)
|
||||||
|
.clientReference(intention.getId().toString())
|
||||||
|
.montant(cotisation.getMontantDu())
|
||||||
|
.statut("EN_ATTENTE")
|
||||||
|
.referenceCotisation(cotisation.getNumeroReference())
|
||||||
|
.message("Ouvrez Wave pour confirmer le versement, puis vous serez renvoyé dans UnionFlow.")
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Flux dépôt épargne ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public VersementGatewayResponse initierDepotEpargneEnLigne(InitierDepotEpargneRequest request) {
|
||||||
|
Membre membreConnecte = getMembreConnecte();
|
||||||
|
CompteEpargne compte = compteEpargneRepository.findByIdOptional(request.compteId())
|
||||||
|
.orElseThrow(() -> new NotFoundException("Compte épargne non trouvé : " + request.compteId()));
|
||||||
|
if (!compte.getMembre().getId().equals(membreConnecte.getId())) {
|
||||||
|
throw new IllegalArgumentException("Ce compte épargne n'appartient pas au membre connecté");
|
||||||
|
}
|
||||||
|
|
||||||
|
String base = waveCheckoutService.getRedirectBaseUrl().replaceAll("/+$", "");
|
||||||
|
BigDecimal montant = request.montant().setScale(0, java.math.RoundingMode.HALF_UP);
|
||||||
|
String objetsCibles = "[{\"type\":\"DEPOT_EPARGNE\",\"compteId\":\""
|
||||||
|
+ request.compteId() + "\",\"montant\":" + montant + "}]";
|
||||||
|
|
||||||
|
IntentionPaiement intention = IntentionPaiement.builder()
|
||||||
|
.utilisateur(membreConnecte)
|
||||||
|
.organisation(compte.getOrganisation())
|
||||||
|
.montantTotal(montant)
|
||||||
|
.codeDevise("XOF")
|
||||||
|
.typeObjet(TypeObjetIntentionPaiement.DEPOT_EPARGNE)
|
||||||
|
.statut(StatutIntentionPaiement.INITIEE)
|
||||||
|
.objetsCibles(objetsCibles)
|
||||||
|
.build();
|
||||||
|
intentionPaiementRepository.persist(intention);
|
||||||
|
|
||||||
|
String successUrl = base + "/api/wave-redirect/success?ref=" + intention.getId();
|
||||||
|
String errorUrl = base + "/api/wave-redirect/error?ref=" + intention.getId();
|
||||||
|
|
||||||
|
WaveCheckoutSessionResponse session;
|
||||||
|
try {
|
||||||
|
session = waveCheckoutService.createSession(
|
||||||
|
montant.toString(), "XOF", successUrl, errorUrl,
|
||||||
|
intention.getId().toString(), toE164(request.numeroTelephone()));
|
||||||
|
} catch (WaveCheckoutException e) {
|
||||||
|
LOG.errorf(e, "Wave Checkout (dépôt épargne) : %s", e.getMessage());
|
||||||
|
intention.setStatut(StatutIntentionPaiement.ECHOUEE);
|
||||||
|
intentionPaiementRepository.persist(intention);
|
||||||
|
throw new jakarta.ws.rs.BadRequestException("Wave : " + e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
intention.setWaveCheckoutSessionId(session.id);
|
||||||
|
intention.setWaveLaunchUrl(session.waveLaunchUrl);
|
||||||
|
intention.setStatut(StatutIntentionPaiement.EN_COURS);
|
||||||
|
intentionPaiementRepository.persist(intention);
|
||||||
|
|
||||||
|
LOG.infof("Dépôt épargne Wave initié : intention=%s, compte=%s",
|
||||||
|
intention.getId(), request.compteId());
|
||||||
|
|
||||||
|
return VersementGatewayResponse.builder()
|
||||||
|
.versementId(intention.getId())
|
||||||
|
.waveLaunchUrl(session.waveLaunchUrl)
|
||||||
|
.waveCheckoutSessionId(session.id)
|
||||||
|
.clientReference(intention.getId().toString())
|
||||||
|
.montant(montant)
|
||||||
|
.statut("EN_ATTENTE")
|
||||||
|
.message("Ouvrez Wave pour confirmer le dépôt, puis vous serez renvoyé dans UnionFlow.")
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Polling statut ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifie le statut d'une intention Wave.
|
||||||
|
* Utilisé par le deep link de retour (mobile) et le polling web.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public VersementStatutResponse verifierStatutVersement(UUID intentionId) {
|
||||||
|
IntentionPaiement intention = intentionPaiementRepository.findById(intentionId);
|
||||||
|
if (intention == null) {
|
||||||
|
throw new NotFoundException("Intention non trouvée : " + intentionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (intention.isCompletee()) {
|
||||||
|
return buildStatutResponse(intention, "Versement confirmé !");
|
||||||
|
}
|
||||||
|
if (StatutIntentionPaiement.EXPIREE.equals(intention.getStatut())
|
||||||
|
|| StatutIntentionPaiement.ECHOUEE.equals(intention.getStatut())) {
|
||||||
|
return buildStatutResponse(intention,
|
||||||
|
"Versement " + intention.getStatut().name().toLowerCase());
|
||||||
|
}
|
||||||
|
if (intention.isExpiree()) {
|
||||||
|
intention.setStatut(StatutIntentionPaiement.EXPIREE);
|
||||||
|
intentionPaiementRepository.persist(intention);
|
||||||
|
return buildStatutResponse(intention, "Session expirée, veuillez recommencer");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (intention.getWaveCheckoutSessionId() != null) {
|
||||||
|
try {
|
||||||
|
WaveCheckoutService.WaveSessionStatusResponse waveStatus =
|
||||||
|
waveCheckoutService.getSession(intention.getWaveCheckoutSessionId());
|
||||||
|
if (waveStatus.isSucceeded()) {
|
||||||
|
confirmerVersementWave(intention, waveStatus.transactionId);
|
||||||
|
return buildStatutResponse(intention, "Versement confirmé !");
|
||||||
|
} else if (waveStatus.isExpired()) {
|
||||||
|
intention.setStatut(StatutIntentionPaiement.EXPIREE);
|
||||||
|
intentionPaiementRepository.persist(intention);
|
||||||
|
return buildStatutResponse(intention, "Session Wave expirée");
|
||||||
|
}
|
||||||
|
} catch (WaveCheckoutService.WaveCheckoutException e) {
|
||||||
|
LOG.warnf(e, "Impossible de vérifier la session Wave %s — retry au prochain appel",
|
||||||
|
intention.getWaveCheckoutSessionId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildStatutResponse(intention, "En attente de confirmation Wave...");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Flux manuel ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public VersementResponse declarerVersementManuel(DeclarerVersementManuelRequest request) {
|
||||||
|
Membre membreConnecte = getMembreConnecte();
|
||||||
|
LOG.infof("Déclaration versement manuel — membre %s, cotisation %s, méthode %s",
|
||||||
|
membreConnecte.getNumeroMembre(), request.cotisationId(), request.methodePaiement());
|
||||||
|
|
||||||
|
Cotisation cotisation = versementRepository.getEntityManager()
|
||||||
|
.createQuery("SELECT c FROM Cotisation c WHERE c.id = :id", Cotisation.class)
|
||||||
|
.setParameter("id", request.cotisationId())
|
||||||
|
.getResultList().stream().findFirst()
|
||||||
|
.orElseThrow(() -> new NotFoundException("Cotisation non trouvée : " + request.cotisationId()));
|
||||||
|
|
||||||
|
if (!cotisation.getMembre().getId().equals(membreConnecte.getId())) {
|
||||||
|
throw new IllegalArgumentException("Cette cotisation n'appartient pas au membre connecté");
|
||||||
|
}
|
||||||
|
|
||||||
|
Versement versement = new Versement();
|
||||||
|
versement.setNumeroReference("VRS-MAN-" + UUID.randomUUID().toString().substring(0, 8).toUpperCase());
|
||||||
|
versement.setMontant(cotisation.getMontantDu());
|
||||||
|
versement.setCodeDevise("XOF");
|
||||||
|
versement.setMethodePaiement(request.methodePaiement());
|
||||||
|
versement.setStatutPaiement("EN_ATTENTE_VALIDATION");
|
||||||
|
versement.setMembre(membreConnecte);
|
||||||
|
versement.setReferenceExterne(request.reference());
|
||||||
|
versement.setCommentaire(request.commentaire());
|
||||||
|
versement.setDatePaiement(LocalDateTime.now());
|
||||||
|
versement.setCreePar(membreConnecte.getEmail());
|
||||||
|
versementRepository.persist(versement);
|
||||||
|
|
||||||
|
// Notifier le trésorier
|
||||||
|
membreOrganisationRepository.findFirstByMembreId(membreConnecte.getId())
|
||||||
|
.ifPresent(mo -> {
|
||||||
|
CreateNotificationRequest notif = CreateNotificationRequest.builder()
|
||||||
|
.typeNotification("VALIDATION_VERSEMENT_REQUIS")
|
||||||
|
.priorite("HAUTE")
|
||||||
|
.sujet("Validation versement manuel requis")
|
||||||
|
.corps("Le membre " + membreConnecte.getNumeroMembre()
|
||||||
|
+ " a déclaré un versement manuel de " + versement.getMontant()
|
||||||
|
+ " XOF (réf: " + versement.getNumeroReference() + ") à valider.")
|
||||||
|
.organisationId(mo.getOrganisation().getId())
|
||||||
|
.build();
|
||||||
|
notificationService.creerNotification(notif);
|
||||||
|
});
|
||||||
|
|
||||||
|
LOG.infof("Versement manuel déclaré : %s (EN_ATTENTE_VALIDATION)", versement.getNumeroReference());
|
||||||
|
return convertToResponse(versement);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Réconciliation Wave (appelé par WaveRedirectResource) ─────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marque l'intention COMPLETEE et met à jour les cotisations cibles.
|
||||||
|
* Idempotent : si déjà complétée, retourne sans effet.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void confirmerVersementWave(IntentionPaiement intention, String waveTransactionId) {
|
||||||
|
if (intention.isCompletee()) return;
|
||||||
|
|
||||||
|
intention.setStatut(StatutIntentionPaiement.COMPLETEE);
|
||||||
|
intention.setDateCompletion(LocalDateTime.now());
|
||||||
|
if (waveTransactionId != null) {
|
||||||
|
intention.setWaveTransactionId(waveTransactionId);
|
||||||
|
}
|
||||||
|
intentionPaiementRepository.persist(intention);
|
||||||
|
|
||||||
|
String objetsCibles = intention.getObjetsCibles();
|
||||||
|
if (objetsCibles == null || objetsCibles.isBlank()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
com.fasterxml.jackson.databind.JsonNode arr =
|
||||||
|
new com.fasterxml.jackson.databind.ObjectMapper().readTree(objetsCibles);
|
||||||
|
if (!arr.isArray()) return;
|
||||||
|
|
||||||
|
for (com.fasterxml.jackson.databind.JsonNode node : arr) {
|
||||||
|
if (!"COTISATION".equals(node.path("type").asText())) continue;
|
||||||
|
|
||||||
|
UUID cotisationId = UUID.fromString(node.get("id").asText());
|
||||||
|
BigDecimal montant = node.has("montant")
|
||||||
|
? new BigDecimal(node.get("montant").asText())
|
||||||
|
: intention.getMontantTotal();
|
||||||
|
|
||||||
|
Cotisation cotisation = versementRepository.getEntityManager()
|
||||||
|
.find(Cotisation.class, cotisationId);
|
||||||
|
if (cotisation == null) continue;
|
||||||
|
|
||||||
|
cotisation.setMontantPaye(montant);
|
||||||
|
cotisation.setStatut("PAYEE");
|
||||||
|
cotisation.setDatePaiement(LocalDateTime.now());
|
||||||
|
versementRepository.getEntityManager().merge(cotisation);
|
||||||
|
LOG.infof("Cotisation %s marquée PAYEE — Wave txn %s",
|
||||||
|
cotisationId, waveTransactionId);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
LOG.errorf(e, "Erreur réconciliation cotisations pour intention %s", intention.getId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Méthodes privées ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private Membre getMembreConnecte() {
|
||||||
|
String email = securityIdentity.getPrincipal().getName();
|
||||||
|
return membreRepository.findByEmail(email)
|
||||||
|
.orElseThrow(() -> new NotFoundException(
|
||||||
|
"Membre non trouvé pour l'email : " + email));
|
||||||
|
}
|
||||||
|
|
||||||
|
private VersementResponse convertToResponse(Versement v) {
|
||||||
|
VersementResponse r = new VersementResponse();
|
||||||
|
r.setId(v.getId());
|
||||||
|
r.setNumeroReference(v.getNumeroReference());
|
||||||
|
r.setMontant(v.getMontant());
|
||||||
|
r.setCodeDevise(v.getCodeDevise());
|
||||||
|
r.setMethodePaiement(v.getMethodePaiement());
|
||||||
|
r.setStatutPaiement(v.getStatutPaiement());
|
||||||
|
r.setDatePaiement(v.getDatePaiement());
|
||||||
|
r.setDateValidation(v.getDateValidation());
|
||||||
|
r.setValidateur(v.getValidateur());
|
||||||
|
r.setReferenceExterne(v.getReferenceExterne());
|
||||||
|
r.setUrlPreuve(v.getUrlPreuve());
|
||||||
|
r.setCommentaire(v.getCommentaire());
|
||||||
|
r.setNumeroTelephone(v.getNumeroTelephone());
|
||||||
|
if (v.getMembre() != null) {
|
||||||
|
r.setMembreId(v.getMembre().getId());
|
||||||
|
}
|
||||||
|
if (v.getTransactionWave() != null) {
|
||||||
|
r.setTransactionWaveId(v.getTransactionWave().getId());
|
||||||
|
}
|
||||||
|
r.setDateCreation(v.getDateCreation());
|
||||||
|
r.setDateModification(v.getDateModification());
|
||||||
|
r.setActif(v.getActif());
|
||||||
|
enrichirLibelles(v, r);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
private VersementSummaryResponse convertToSummaryResponse(Versement v) {
|
||||||
|
if (v == null) return null;
|
||||||
|
return new VersementSummaryResponse(
|
||||||
|
v.getId(),
|
||||||
|
v.getNumeroReference(),
|
||||||
|
v.getMontant(),
|
||||||
|
v.getCodeDevise(),
|
||||||
|
resolveLibelle("METHODE_PAIEMENT", v.getMethodePaiement()),
|
||||||
|
v.getStatutPaiement(),
|
||||||
|
resolveLibelle("STATUT_PAIEMENT", v.getStatutPaiement()),
|
||||||
|
resolveSeverity("STATUT_PAIEMENT", v.getStatutPaiement()),
|
||||||
|
v.getDatePaiement());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void enrichirLibelles(Versement v, VersementResponse r) {
|
||||||
|
r.setMethodePaiementLibelle(resolveLibelle("METHODE_PAIEMENT", v.getMethodePaiement()));
|
||||||
|
r.setStatutPaiementLibelle(resolveLibelle("STATUT_PAIEMENT", v.getStatutPaiement()));
|
||||||
|
r.setStatutPaiementSeverity(resolveSeverity("STATUT_PAIEMENT", v.getStatutPaiement()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolveLibelle(String domaine, String code) {
|
||||||
|
if (code == null) return null;
|
||||||
|
return typeReferenceRepository.findByDomaineAndCode(domaine, code)
|
||||||
|
.map(dev.lions.unionflow.server.entity.TypeReference::getLibelle)
|
||||||
|
.orElse(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolveSeverity(String domaine, String code) {
|
||||||
|
if (code == null) return null;
|
||||||
|
return typeReferenceRepository.findByDomaineAndCode(domaine, code)
|
||||||
|
.map(dev.lions.unionflow.server.entity.TypeReference::getSeverity)
|
||||||
|
.orElse("info");
|
||||||
|
}
|
||||||
|
|
||||||
|
private VersementStatutResponse buildStatutResponse(IntentionPaiement intention, String message) {
|
||||||
|
return VersementStatutResponse.builder()
|
||||||
|
.intentionId(intention.getId())
|
||||||
|
.statut(intention.getStatut().name())
|
||||||
|
.confirme(intention.isCompletee())
|
||||||
|
.waveLaunchUrl(intention.getWaveLaunchUrl())
|
||||||
|
.waveCheckoutSessionId(intention.getWaveCheckoutSessionId())
|
||||||
|
.waveTransactionId(intention.getWaveTransactionId())
|
||||||
|
.montant(intention.getMontantTotal())
|
||||||
|
.message(message)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Format E.164 pour Wave : 771234567 → +221771234567 */
|
||||||
|
static String toE164(String numeroTelephone) {
|
||||||
|
if (numeroTelephone == null || numeroTelephone.isBlank()) return null;
|
||||||
|
String digits = numeroTelephone.replaceAll("\\D", "");
|
||||||
|
if (digits.length() == 9 && (digits.startsWith("7") || digits.startsWith("0"))) {
|
||||||
|
return "+221" + (digits.startsWith("0") ? digits.substring(1) : digits);
|
||||||
|
}
|
||||||
|
if (digits.length() >= 9 && digits.startsWith("221")) return "+" + digits;
|
||||||
|
return numeroTelephone.startsWith("+") ? numeroTelephone : "+" + digits;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- V27 : Ajoute la colonne numero_telephone dans la table paiements (versements)
|
||||||
|
--
|
||||||
|
-- Contexte : La refonte conceptuelle remplace "Paiement" par "Versement".
|
||||||
|
-- L'application Wave est installée sur le même téléphone qu'UnionFlow ;
|
||||||
|
-- le numéro de téléphone du membre est envoyé automatiquement depuis son profil
|
||||||
|
-- afin de pré-remplir le formulaire Wave (deep link natif).
|
||||||
|
-- Ce champ mémorise le numéro utilisé lors du versement Wave.
|
||||||
|
|
||||||
|
ALTER TABLE paiements
|
||||||
|
ADD COLUMN IF NOT EXISTS numero_telephone VARCHAR(20);
|
||||||
Reference in New Issue
Block a user